diff --git a/.sampo/changesets/flags-typed-accessors.md b/.sampo/changesets/flags-typed-accessors.md new file mode 100644 index 00000000..ea408e64 --- /dev/null +++ b/.sampo/changesets/flags-typed-accessors.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Add `get_boolean_value`, `get_string_value`, `get_number_value`, `get_integer_value`, `get_float_value` and `get_object_value` to the `evaluate_flags()` result, each with a `_details` form that returns a `FlagEvaluationDetails` with an OpenFeature `reason`, `error_code` and `flag_metadata`. These accessors never coerce and never raise: they return your default when the flag is missing, failed, unreadable, has no value, or has a value of another type, and an object value too deeply nested to copy reads as unreadable. A read of a value of another type (`TYPE_MISMATCH`) is a bug in the calling code: it sends no `$feature_flag_called` and leaves the dedupe cache alone, but still counts as accessed for `only_accessed()`. The next read of the right type, or a `get_flag()` or `is_enabled()` read, reports the flag. The SDK logs the details' `error_message` as an error once per client for each flag and requested type. Numbers are IEEE-754 doubles: `get_number_value` returns an integer beyond `2**53 - 1` in magnitude as the nearest `float`, `get_float_value` returns every number as the nearest `float`, and both return an infinity for an integer beyond the range of a double, as JavaScript parses it. `get_integer_value` returns your default with `TYPE_MISMATCH` for an integer beyond `2**53 - 1`. Each accessor takes the keyword-only `send_feature_flag_events` option, like `is_enabled()` and `get_flag()`: pass `send_feature_flag_events=False` to read a flag without sending `$feature_flag_called`. The key still counts as accessed for `only_accessed()`, and a later read that sends events still reports the flag. Details of a locally evaluated flag have `config_version` 1 and carry the `id` and `version` of the local flag definition in `flag_metadata`. `FeatureFlag` gains `value` (the typed value), `failed`, and `error_code` and `error_message`, which say why a flag failed or could not be read. `FlagMetadata` gains `config_version`, `rule_type`, `rule_id`, `variant_key` and `forced_variant`. Number and object values need a server that sends typed values; the SDK logs once per client when it reads a response without them. diff --git a/.sampo/changesets/snapshot-flag-event-option.md b/.sampo/changesets/snapshot-flag-event-option.md new file mode 100644 index 00000000..9668d431 --- /dev/null +++ b/.sampo/changesets/snapshot-flag-event-option.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Add a per-read `send_feature_flag_events` option to `FeatureFlagEvaluations.is_enabled()` and `get_flag()`. Pass `send_feature_flag_events=False` to read a flag from an `evaluate_flags()` snapshot without sending `$feature_flag_called`. The read leaves the dedupe cache alone, so a later read that sends events still reports the flag, and the key still counts as accessed for `only_accessed()`. The option defaults to `True`, as on `get_feature_flag()`. diff --git a/posthog/__init__.py b/posthog/__init__.py index d9f6d174..9f5ed0b9 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -81,6 +81,9 @@ BeforeSendCallback as BeforeSendCallback, FeatureFlag as FeatureFlag, FeatureFlagEvaluationRuntime as FeatureFlagEvaluationRuntime, + FlagEvaluationDetails as FlagEvaluationDetails, + FlagEvaluationErrorCode as FlagEvaluationErrorCode, + FlagEvaluationReason as FlagEvaluationReason, FlagValue as FlagValue, FlagsAndPayloads as FlagsAndPayloads, UnresolvedFlagReason as UnresolvedFlagReason, diff --git a/posthog/async_client.py b/posthog/async_client.py index 323b16b8..a218c6d6 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -231,6 +231,9 @@ def __init__( self.log.setLevel(logging.WARNING) # Whether this client has logged that /flags sent records without typed values. self._logged_untyped_flags_response = False + # The (flag key, requested type) pairs of typed reads of the wrong type that + # this client has logged. Same bound and eviction as the flag-event dedupe. + self._logged_flag_type_mismatches = SizeLimitedDict(_MAX_DICT_SIZE) if not self.api_key: self.log.error( @@ -850,9 +853,18 @@ def _get_feature_flag_evaluations_host(self) -> _FeatureFlagEvaluationsHost: self._feature_flag_evaluations_host_cache = _FeatureFlagEvaluationsHost( capture_flag_called_event_if_needed=self._capture_feature_flag_called_if_needed, log_warning=lambda message: self.log.warning(message), + log_type_mismatch=self._log_flag_type_mismatch, ) return self._feature_flag_evaluations_host_cache + def _log_flag_type_mismatch(self, key: str, value_type: str, message: str) -> None: + """Log a typed flag read of the wrong type, once per flag key and type.""" + logged_key = (key, value_type) + if logged_key in self._logged_flag_type_mismatches: + return + self._logged_flag_type_mismatches[logged_key] = True + self.log.error(message) + async def _get_flags_decision( self, distinct_id: ID_TYPES, diff --git a/posthog/client.py b/posthog/client.py index 4c090029..60d081ef 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1007,6 +1007,9 @@ def __init__( self._minimal_flag_called_events: bool = False # Whether this client has logged that /flags sent records without typed values. self._logged_untyped_flags_response = False + # The (flag key, requested type) pairs of typed reads of the wrong type that + # this client has logged. Same bound and eviction as the flag-event dedupe. + self._logged_flag_type_mismatches = SizeLimitedDict(MAX_DICT_SIZE) self.capture_trace_context = capture_trace_context self.capture_exception_code_variables = capture_exception_code_variables @@ -4928,11 +4931,10 @@ def evaluate_flags( key=key, enabled=value is not False, variant=value if isinstance(value, str) else None, + inactive=value is False and not flag_def.get("active"), payload=_parse_flag_payload(local_payloads.get(key)), id=flag_def.get("id"), - # The local-evaluation flag definition does not carry a version field; - # only the remote ``/flags`` response does via ``metadata.version``. - version=None, + version=flag_def.get("version"), reason="Evaluated locally", locally_evaluated=True, has_experiment=_parse_has_experiment(flag_def.get("has_experiment")), @@ -5029,9 +5031,18 @@ def _get_feature_flag_evaluations_host(self) -> _FeatureFlagEvaluationsHost: self._feature_flag_evaluations_host_cache = _FeatureFlagEvaluationsHost( capture_flag_called_event_if_needed=self._capture_feature_flag_called_if_needed, log_warning=lambda message: self.log.warning(message), + log_type_mismatch=self._log_flag_type_mismatch, ) return self._feature_flag_evaluations_host_cache + def _log_flag_type_mismatch(self, key: str, value_type: str, message: str) -> None: + """Log a typed flag read of the wrong type, once per flag key and type.""" + logged_key = (key, value_type) + if logged_key in self._logged_flag_type_mismatches: + return + self._logged_flag_type_mismatches[logged_key] = True + self.log.error(message) + def _get_all_flags_and_payloads_locally( self, distinct_id: ID_TYPES, diff --git a/posthog/feature_flag_evaluations.py b/posthog/feature_flag_evaluations.py index ef4e401d..611084f8 100644 --- a/posthog/feature_flag_evaluations.py +++ b/posthog/feature_flag_evaluations.py @@ -5,11 +5,19 @@ values the code branched on, with no additional /flags request. """ -from dataclasses import dataclass +from dataclasses import dataclass, replace from types import MappingProxyType from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Union -from posthog.types import FeatureFlag, FlagValue, UnresolvedFlagReason +from posthog.types import ( + FeatureFlag, + FlagEvaluationDetails, + FlagEvaluationErrorCode, + FlagValue, + UnresolvedFlagReason, + _resolve_flag_details, + _resolve_flag_value, +) @dataclass(frozen=True) @@ -30,6 +38,8 @@ class _EvaluatedFlagRecord: # The parsed /flags record, with its typed value, reason and metadata. # ``None`` for a locally evaluated flag. details: Optional[FeatureFlag] = None + # Whether a locally evaluated flag is off because its definition is inactive. + inactive: bool = False @dataclass @@ -42,6 +52,9 @@ class _FeatureFlagEvaluationsHost: capture_flag_called_event_if_needed: Callable[..., None] log_warning: Callable[[str], None] + # Logs a typed read of the wrong type as an error, once per flag key and + # requested type: ``(key, value_type, message)``. + log_type_mismatch: Callable[[str, str, str], None] class FeatureFlagEvaluations: @@ -98,7 +111,13 @@ def __init__( unresolved_flags or {} ) - def is_enabled(self, key: str, default_value: bool = False) -> bool: + def is_enabled( + self, + key: str, + default_value: bool = False, + *, + send_feature_flag_events: bool = True, + ) -> bool: """Return whether the flag is enabled. Fires ``$feature_flag_called`` on the first access per (distinct_id, flag, value) tuple, deduped via the SDK's cache. @@ -108,29 +127,238 @@ def is_enabled(self, key: str, default_value: bool = False) -> bool: the key was not part of the evaluated set, or the evaluation itself came back empty (failed ``/flags`` request, quota limit, or no resolvable ``distinct_id``). Defaults to ``False``. + send_feature_flag_events: Whether this read sends ``$feature_flag_called``. + When ``False``, no event is sent and the dedupe cache is left alone, + but the key still counts as accessed for :meth:`only_accessed`. A flag that has a value always wins over ``default_value``, including a disabled flag, which returns ``False`` even when ``default_value=True``. """ flag = self._flags.get(key) - self._record_access(key) + self._record_access(key, send_feature_flag_events) return bool(flag.enabled) if flag else default_value - def get_flag(self, key: str) -> Optional[FlagValue]: + def get_flag( + self, key: str, *, send_feature_flag_events: bool = True + ) -> Optional[FlagValue]: """Return the flag value. Fires ``$feature_flag_called`` on first access. Returns the variant string for multivariate flags, ``True`` for enabled flags without a variant, ``False`` for disabled flags, and ``None`` for flags that were not returned by the evaluation. + + Pass ``send_feature_flag_events=False`` to skip the event for this read, as + with :meth:`is_enabled`. """ flag = self._flags.get(key) - self._record_access(key) + self._record_access(key, send_feature_flag_events) if not flag: return None if not flag.enabled: return False return flag.variant if flag.variant is not None else True + def get_boolean_value( + self, key: str, default_value: bool, *, send_feature_flag_events: bool = True + ) -> bool: + """Return the flag's boolean value, or ``default_value``. + + Like :meth:`get_flag`, fires ``$feature_flag_called`` on first access unless + ``send_feature_flag_events`` is ``False``. See :meth:`get_boolean_details` for + when the default is returned. + """ + return self.get_boolean_details( + key, default_value, send_feature_flag_events=send_feature_flag_events + ).value + + def get_boolean_details( + self, key: str, default_value: bool, *, send_feature_flag_events: bool = True + ) -> FlagEvaluationDetails[bool]: + """Return the flag's boolean value with its evaluation details. + + The value is never coerced: a variant string is not ``True``. + ``default_value`` is returned when the flag is not in the evaluation, + failed, has no value, or has a value of another type; ``error_code`` says + which, except for a flag without a value. A flag whose value is ``False`` + returns ``False``. Fires ``$feature_flag_called`` on first access, like + :meth:`get_flag`, except for a value of another type, which sends no event + and logs an error once per client. + + Pass ``send_feature_flag_events=False`` to skip the event for this read, as + with :meth:`is_enabled`. The key still counts as accessed for + :meth:`only_accessed`. + """ + return self._get_typed_details( + key, default_value, "boolean", send_feature_flag_events + ) + + def get_string_value( + self, key: str, default_value: str, *, send_feature_flag_events: bool = True + ) -> str: + """Return the flag's string value, or ``default_value``. + + Like :meth:`get_flag`, fires ``$feature_flag_called`` on first access unless + ``send_feature_flag_events`` is ``False``. See :meth:`get_string_details` for + when the default is returned. + """ + return self.get_string_details( + key, default_value, send_feature_flag_events=send_feature_flag_events + ).value + + def get_string_details( + self, key: str, default_value: str, *, send_feature_flag_events: bool = True + ) -> FlagEvaluationDetails[str]: + """Return the flag's string value with its evaluation details. + + The value is never coerced. ``default_value`` is returned when the flag is + not in the evaluation, failed, has no value, or has a value of another + type; ``error_code`` says which, except for a flag without a value or with + the value ``False``. Fires ``$feature_flag_called`` on first access, like + :meth:`get_flag`, except for a value of another type, which sends no event + and logs an error once per client. + + Pass ``send_feature_flag_events=False`` to skip the event for this read, as + with :meth:`is_enabled`. The key still counts as accessed for + :meth:`only_accessed`. + """ + return self._get_typed_details( + key, default_value, "string", send_feature_flag_events + ) + + def get_number_value( + self, key: str, default_value: float, *, send_feature_flag_events: bool = True + ) -> float: + """Return the flag's number value, or ``default_value``. + + An integer value is returned as an ``int``, or as a ``float`` beyond + ``2**53 - 1`` in magnitude. Like :meth:`get_flag`, fires + ``$feature_flag_called`` on first access unless ``send_feature_flag_events`` + is ``False``. See :meth:`get_number_details` for when the default is + returned. + """ + return self.get_number_details( + key, default_value, send_feature_flag_events=send_feature_flag_events + ).value + + def get_number_details( + self, key: str, default_value: float, *, send_feature_flag_events: bool = True + ) -> FlagEvaluationDetails[float]: + """Return the flag's number value with its evaluation details. + + The value is never coerced: a boolean or a numeric string is not a number. + Numbers are IEEE-754 doubles, so an integer beyond ``2**53 - 1`` in + magnitude is returned as the nearest ``float``, as every PostHog SDK reads + it, and one beyond the range of a double as an infinity. ``default_value`` + is returned when the flag is not in the evaluation, failed, has no value, + or has a value of another type; ``error_code`` says which, except for a + flag without a value or with the value ``False``. Fires + ``$feature_flag_called`` on first access, like :meth:`get_flag`, except for + a value of another type, which sends no event and logs an error once per + client. + + Pass ``send_feature_flag_events=False`` to skip the event for this read, as + with :meth:`is_enabled`. The key still counts as accessed for + :meth:`only_accessed`. + """ + return self._get_typed_details( + key, default_value, "number", send_feature_flag_events + ) + + def get_integer_value( + self, key: str, default_value: int, *, send_feature_flag_events: bool = True + ) -> int: + """Return the flag's number value as an ``int``, or ``default_value``. + + Like :meth:`get_flag`, fires ``$feature_flag_called`` on first access unless + ``send_feature_flag_events`` is ``False``. See :meth:`get_integer_details` for + when the default is returned. + """ + return self.get_integer_details( + key, default_value, send_feature_flag_events=send_feature_flag_events + ).value + + def get_integer_details( + self, key: str, default_value: int, *, send_feature_flag_events: bool = True + ) -> FlagEvaluationDetails[int]: + """Return the flag's number value as an ``int`` with its evaluation details. + + Like :meth:`get_number_details`, but a number with a fractional part, such + as ``0.25``, or beyond ``2**53 - 1`` in magnitude is a ``TYPE_MISMATCH``, so + every PostHog SDK returns the same result. An integral number such as + ``2.0`` is returned as ``2``. + """ + return self._get_typed_details( + key, default_value, "integer", send_feature_flag_events + ) + + def get_float_value( + self, key: str, default_value: float, *, send_feature_flag_events: bool = True + ) -> float: + """Return the flag's number value as a ``float``, or ``default_value``. + + Like :meth:`get_flag`, fires ``$feature_flag_called`` on first access unless + ``send_feature_flag_events`` is ``False``. See :meth:`get_float_details` for + when the default is returned. + """ + return self.get_float_details( + key, default_value, send_feature_flag_events=send_feature_flag_events + ).value + + def get_float_details( + self, key: str, default_value: float, *, send_feature_flag_events: bool = True + ) -> FlagEvaluationDetails[float]: + """Return the flag's number value as a ``float`` with its evaluation details. + + Like :meth:`get_number_details`, but every integer value is returned as the + nearest ``float``. + """ + return self._get_typed_details( + key, default_value, "float", send_feature_flag_events + ) + + def get_object_value( + self, + key: str, + default_value: Dict[str, Any], + *, + send_feature_flag_events: bool = True, + ) -> Dict[str, Any]: + """Return the flag's object value, or ``default_value``. + + Like :meth:`get_flag`, fires ``$feature_flag_called`` on first access unless + ``send_feature_flag_events`` is ``False``. See :meth:`get_object_details` for + when the default is returned. + """ + return self.get_object_details( + key, default_value, send_feature_flag_events=send_feature_flag_events + ).value + + def get_object_details( + self, + key: str, + default_value: Dict[str, Any], + *, + send_feature_flag_events: bool = True, + ) -> FlagEvaluationDetails[Dict[str, Any]]: + """Return the flag's object value with its evaluation details. + + The value is never coerced: a payload is not an object value. The returned + object is a copy. ``default_value`` is returned when the flag is not in the + evaluation, failed, has no value, has a value of another type, or has an + object too deeply nested to copy; ``error_code`` says which, except for a + flag without a value or with the value ``False``. Fires + ``$feature_flag_called`` on first access, like :meth:`get_flag`, except for + a value of another type, which sends no event and logs an error once per + client. + + Pass ``send_feature_flag_events=False`` to skip the event for this read, as + with :meth:`is_enabled`. The key still counts as accessed for + :meth:`only_accessed`. + """ + return self._get_typed_details( + key, default_value, "object", send_feature_flag_events + ) + def get_flag_payload(self, key: str) -> Optional[Any]: """Return the payload associated with a flag. @@ -140,8 +368,8 @@ def get_flag_payload(self, key: str) -> Optional[Any]: return flag.payload if flag else None def only_accessed(self) -> "FeatureFlagEvaluations": - """Return a filtered copy containing only flags accessed via :meth:`is_enabled` - or :meth:`get_flag` before this call. + """Return a filtered copy containing only flags accessed via :meth:`is_enabled`, + :meth:`get_flag` or a typed accessor before this call. Order-dependent: if nothing has been accessed yet, the returned snapshot is empty. The method honors its name — pre-access if you want a populated result. @@ -239,9 +467,69 @@ def _clone_with( accessed=set(self._accessed), ) - def _record_access(self, key: str) -> None: + def _get_typed_details( + self, + key: str, + default_value: Any, + value_type: str, + send_feature_flag_events: bool, + ) -> FlagEvaluationDetails[Any]: + details = self._resolve_typed_details(key, default_value, value_type) + if details.error_code is FlagEvaluationErrorCode.TYPE_MISMATCH: + # A read of the wrong type is a bug in the calling code, not a use of the + # flag. It sends no event and takes no dedupe slot, so the next read of + # the right type reports the flag. It still counts as an access. + self._accessed.add(key) + self._host.log_type_mismatch(key, value_type, details.error_message or "") + else: + self._record_access(key, send_feature_flag_events) + return details + + def _resolve_typed_details( + self, key: str, default_value: Any, value_type: str + ) -> FlagEvaluationDetails[Any]: + flag = self._flags.get(key) + if flag is None and key in self._unresolved_flags: + # The flag exists, so say why it has no value. + return replace( + _resolve_flag_details(key, None, default_value, value_type), + error_message=f"Local evaluation could not resolve flag '{key}' " + f"({self._unresolved_flags[key].value}).", + ) + if flag is None or flag.details is not None: + return _resolve_flag_details( + key, flag.details if flag else None, default_value, value_type + ) + # A locally evaluated flag has config version 1: its value is the variant, + # or whether it is enabled. + value = flag.variant if flag.variant is not None else flag.enabled + reason_code = "flag_disabled" if flag.inactive else None + flag_metadata = { + "id": flag.id, + "version": flag.version, + "config_version": 1, + "reason_code": reason_code, + "variant_key": flag.variant, + } + return _resolve_flag_value( + key, + value, + default_value, + value_type, + config_version=1, + reason_code=reason_code, + variant=flag.variant, + flag_metadata={k: v for k, v in flag_metadata.items() if v is not None}, + ) + + def _record_access(self, key: str, send_feature_flag_events: bool) -> None: self._accessed.add(key) + # A suppressed read skips the dedupe cache too, so a later read that sends + # events still reports this flag. + if not send_feature_flag_events: + return + # Empty snapshots (no resolvable distinct_id) are returned by ``evaluate_flags()`` # as a safety fallback. Firing $feature_flag_called for them would emit events # with an empty distinct_id, polluting analytics — short-circuit here. diff --git a/posthog/test/test_evaluate_flags.py b/posthog/test/test_evaluate_flags.py index 25ca18e5..dec98a76 100644 --- a/posthog/test/test_evaluate_flags.py +++ b/posthog/test/test_evaluate_flags.py @@ -1301,6 +1301,126 @@ def test_filtered_snapshots_do_not_back_propagate_access(self, patch_flags): self.assertEqual(flags.only_accessed().keys, ["boolean-flag"]) +class TestEvaluateFlagsPerReadEventOption(unittest.TestCase): + """Per-read ``send_feature_flag_events`` on snapshot reads (sdk-specs + evaluate-flags "Snapshot feature flag event control").""" + + def setUp(self): + self.client = Client(FAKE_TEST_API_KEY) + + def tearDown(self): + self.client.shutdown() + + @staticmethod + def _flag_called(patch_capture): + return [ + (c[1]["properties"]["$feature_flag"], c[1]["properties"]) + for c in patch_capture.call_args_list + if c[0] and c[0][0] == "$feature_flag_called" + ] + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_option_false_silences_one_read(self, patch_capture, patch_flags): + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + + enabled = flags.is_enabled("boolean-flag", send_feature_flag_events=False) + value = flags.get_flag("variant-flag") + + self.assertEqual((enabled, value), (True, "variant-value")) + self.assertEqual(patch_flags.call_count, 1) + events = self._flag_called(patch_capture) + self.assertEqual([key for key, _ in events], ["variant-flag"]) + self.assertEqual(events[0][1]["$feature_flag_response"], "variant-value") + self.assertEqual(patch_capture.call_args[1]["distinct_id"], "user-1") + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_option_true_sends_events(self, patch_capture, patch_flags): + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + + flags.is_enabled("boolean-flag", send_feature_flag_events=True) + flags.get_flag("variant-flag", send_feature_flag_events=True) + + self.assertEqual( + [key for key, _ in self._flag_called(patch_capture)], + ["boolean-flag", "variant-flag"], + ) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_suppressed_read_leaves_dedupe_untouched(self, patch_capture, patch_flags): + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + + results = [ + flags.is_enabled("boolean-flag", send_feature_flag_events=False), + flags.get_flag("boolean-flag"), + flags.is_enabled("boolean-flag"), + ] + + self.assertEqual(results, [True, True, True]) + self.assertEqual(patch_flags.call_count, 1) + events = self._flag_called(patch_capture) + self.assertEqual([key for key, _ in events], ["boolean-flag"]) + self.assertIs(events[0][1]["$feature_flag_response"], True) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_suppressed_read_leaves_single_flag_getter_exposure_intact( + self, patch_capture, patch_flags + ): + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + + snapshot_value = flags.is_enabled( + "boolean-flag", send_feature_flag_events=False + ) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + getter_value = self.client.get_feature_flag("boolean-flag", "user-1") + + self.assertEqual((snapshot_value, getter_value), (True, True)) + events = self._flag_called(patch_capture) + self.assertEqual([key for key, _ in events], ["boolean-flag"]) + self.assertIs(events[0][1]["$feature_flag_response"], True) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_suppressed_reads_still_count_for_only_accessed( + self, patch_capture, patch_flags + ): + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + + enabled = flags.is_enabled("boolean-flag", send_feature_flag_events=False) + value = flags.get_flag("disabled-flag", send_feature_flag_events=False) + + self.assertEqual((enabled, value), (True, False)) + self.assertEqual( + sorted(flags.only_accessed().keys), ["boolean-flag", "disabled-flag"] + ) + self.assertEqual(patch_flags.call_count, 1) + self.assertEqual(self._flag_called(patch_capture), []) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_suppressed_missing_key_reports_no_missing_flag( + self, patch_capture, patch_flags + ): + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + + enabled = flags.is_enabled("missing-flag", send_feature_flag_events=False) + value = flags.get_flag("missing-flag", send_feature_flag_events=False) + + self.assertEqual((enabled, value), (False, None)) + self.assertEqual(patch_flags.call_count, 1) + self.assertEqual(self._flag_called(patch_capture), []) + + class TestCaptureWithFlagsSnapshot(unittest.TestCase): def setUp(self): self.client = Client(FAKE_TEST_API_KEY) diff --git a/posthog/test/test_feature_flag.py b/posthog/test/test_feature_flag.py index 29db7e61..411fbf9f 100644 --- a/posthog/test/test_feature_flag.py +++ b/posthog/test/test_feature_flag.py @@ -1,12 +1,6 @@ import unittest -from posthog.types import ( - FeatureFlag, - FlagMetadata, - FlagReason, - LegacyFlagMetadata, - _set_private_fields, -) +from posthog.types import FeatureFlag, FlagMetadata, FlagReason, LegacyFlagMetadata class TestFeatureFlag(unittest.TestCase): @@ -44,14 +38,12 @@ def test_feature_flag_from_json(self): ) self.assertEqual( flag.metadata, - _set_private_fields( - FlagMetadata( - id=1, - payload='{"some": "json"}', - version=2, - description="test-description", - ), - _config_version=1, + FlagMetadata( + id=1, + payload='{"some": "json"}', + version=2, + description="test-description", + config_version=1, ), ) diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py index a0b3011a..f019e751 100644 --- a/posthog/test/test_flags_v4_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -7,11 +7,13 @@ from __future__ import annotations +import contextlib import copy import hashlib import json +import math from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, AsyncIterator, Callable, Dict, List, Optional, Tuple from unittest import mock import pytest @@ -23,9 +25,12 @@ from posthog.types import ( FeatureFlag, FeatureFlagResult, + FlagEvaluationDetails, + FlagEvaluationErrorCode, + FlagEvaluationReason, FlagMetadata, FlagReason, - _set_private_fields, + UnresolvedFlagReason, normalize_flags_response, to_flags_and_payloads, ) @@ -80,6 +85,32 @@ def _evaluate(client: Client, response: Dict[str, Any]) -> FeatureFlagEvaluation return client.evaluate_flags(DISTINCT_ID) +def _details( + snapshot: FeatureFlagEvaluations, key: str, default: Any, expected: Any = None +) -> FlagEvaluationDetails[Any]: + """Read through the typed accessor that matches the expected value's type.""" + if isinstance(expected, str): + return snapshot.get_string_details(key, default) + return snapshot.get_boolean_details(key, default) + + +def _complete_split(details: FlagEvaluationDetails[Any]) -> bool: + """Whether the retained details carry a complete experiment split. + + Exposure events are not emitted yet; this checks that the retained details hold + everything the exposure predicate needs, and nothing from a partial record. + """ + metadata = details.flag_metadata + return ( + details.error_code is None + and metadata.get("config_version") == 2 + and metadata.get("reason_code") == "experiment_split" + and metadata.get("rule_type") == "experiment" + and metadata.get("rule_id") is not None + and metadata.get("variant_key") is not None + ) + + def test_vendored_fixtures_match_the_recorded_release_digests(): source = _load("SOURCE.json") assert source["release"] == "1.22.0" @@ -89,14 +120,30 @@ def test_vendored_fixtures_match_the_recorded_release_digests(): assert digest == entry["sha256"], name -# The /flags?v=4 metadata fields that stay private until the typed accessors ship. -_PRIVATE_METADATA_FIELDS = { - "config_version", - "rule_type", - "rule_id", - "variant_key", - "forced_variant", -} +@pytest.mark.parametrize("case", READERS["cases"], ids=lambda case: case["id"]) +def test_reader_fixture(client, case): + snapshot = _evaluate(client, _build(READERS, case)) + reader = case["reader"] + key = reader["requested_key"] + + details = _details(snapshot, key, reader["caller_default"], reader["value"]) + + assert details.value == reader["value"] + assert type(details.value) is type(reader["value"]) + expected_error = reader["error_code"] + assert details.error_code == ( + FlagEvaluationErrorCode(expected_error) if expected_error else None + ) + assert details.flag_metadata.get("config_version") == reader["config_version"] + assert _complete_split(details) is reader["experiment_exposure"] + + for sibling, expected in case.get("sibling_expectations", {}).items(): + sibling_details = snapshot.get_boolean_details(sibling, False) + assert sibling_details.error_code == FlagEvaluationErrorCode( + expected["error_code"] + ) + assert _complete_split(sibling_details) is expected["experiment_exposure"] + VALID_RESPONSES = [case for case in RESPONSES["cases"] if case["expected"] == "valid"] @@ -110,8 +157,9 @@ def test_valid_producer_responses_are_retained_as_sent(case): assert set(flags) == set(records) for key, record in records.items(): flag = flags[key] - assert flag._value == record["value"] - assert flag._failed is record.get("failed", False) + assert flag.error_code is not FlagEvaluationErrorCode.PARSE_ERROR + assert flag.value == record["value"] + assert flag.failed is record.get("failed", False) assert flag.reason is not None assert flag.reason.code == record["reason"]["code"] assert flag.reason.condition_index == record["reason"]["condition_index"] @@ -119,8 +167,6 @@ def test_valid_producer_responses_are_retained_as_sent(case): for field, value in record["metadata"].items(): # Only config version 1 records carry an experiment ID; it is not kept. if field not in ("payload", "experiment_id"): - if field in _PRIVATE_METADATA_FIELDS: - field = f"_{field}" assert getattr(flag.metadata, field) == value, field @@ -154,6 +200,330 @@ def _typed_response(values: Dict[str, Any]) -> Dict[str, Any]: } +TYPED_VALUES = { + "boolean-flag": True, + "false-flag": False, + "null-flag": None, + "string-flag": "compact", + "empty-string-flag": "", + "integer-flag": 7, + "zero-flag": 0, + "float-flag": 12.5, + "object-flag": {"layout": "compact", "columns": [1, 2], "nested": {"on": True}}, + "empty-object-flag": {}, +} + +ACCESSORS: Dict[str, Callable[[FeatureFlagEvaluations], Callable[..., Any]]] = { + "boolean": lambda snapshot: snapshot.get_boolean_details, + "string": lambda snapshot: snapshot.get_string_details, + "number": lambda snapshot: snapshot.get_number_details, + "integer": lambda snapshot: snapshot.get_integer_details, + "float": lambda snapshot: snapshot.get_float_details, + "object": lambda snapshot: snapshot.get_object_details, +} +DEFAULTS = { + "boolean": True, + "string": "default", + "number": -1, + "integer": -1, + "float": -1.0, + "object": {"d": 1}, +} +# The accessors that return each flag's value, and the value they return. +ACCEPTED = { + "boolean-flag": {"boolean": True}, + "false-flag": {"boolean": False}, + "string-flag": {"string": "compact"}, + "empty-string-flag": {"string": ""}, + "integer-flag": {"number": 7, "integer": 7, "float": 7.0}, + "zero-flag": {"number": 0, "integer": 0, "float": 0.0}, + "float-flag": {"number": 12.5, "float": 12.5}, + "object-flag": {"object": TYPED_VALUES["object-flag"]}, + "empty-object-flag": {"object": {}}, +} + + +@pytest.mark.parametrize("key", list(TYPED_VALUES)) +@pytest.mark.parametrize("value_type", list(ACCESSORS)) +def test_typed_accessors_never_coerce(client, key, value_type): + snapshot = _evaluate(client, _typed_response(TYPED_VALUES)) + default = DEFAULTS[value_type] + + details = ACCESSORS[value_type](snapshot)(key, default) + + value = TYPED_VALUES[key] + assert details.key == key + assert details.flag_metadata["reason_code"] == "targeting_match" + assert details.flag_metadata["config_version"] == 2 + accepted = ACCEPTED.get(key, {}) + if value_type in accepted: + assert details.value == accepted[value_type] + assert type(details.value) is type(accepted[value_type]) + assert details.error_code is None + assert details.reason is FlagEvaluationReason.TARGETING_MATCH + elif value is None or value is False: + # No value, or false read as another type: the default, without an error. + assert details.value == default + assert details.error_code is None + assert details.reason is FlagEvaluationReason.TARGETING_MATCH + else: + assert details.value == default + assert details.error_code is FlagEvaluationErrorCode.TYPE_MISMATCH + assert details.reason is FlagEvaluationReason.ERROR + + +def test_value_accessors_return_the_details_value(client): + snapshot = _evaluate(client, _typed_response(TYPED_VALUES)) + + assert snapshot.get_boolean_value("boolean-flag", False) is True + assert snapshot.get_boolean_value("false-flag", True) is False + assert snapshot.get_boolean_value("null-flag", True) is True + assert snapshot.get_boolean_value("string-flag", True) is True + assert snapshot.get_string_value("string-flag", "x") == "compact" + assert snapshot.get_string_value("false-flag", "x") == "x" + assert snapshot.get_number_value("integer-flag", 0) == 7 + assert snapshot.get_number_value("boolean-flag", 0) == 0 + assert snapshot.get_object_value("object-flag", {}) == TYPED_VALUES["object-flag"] + assert snapshot.get_object_value("missing-flag", {"a": 1}) == {"a": 1} + + +def test_typed_details_carry_the_variant_key_and_context(client): + response = _build(READERS, {"template": "experiment_split"}) + response["flags"]["garden-layout"]["value"] = "compact" + snapshot = _evaluate(client, response) + + details = snapshot.get_string_details("garden-layout", "default") + + assert details.value == "compact" + assert details.variant == "arm_a" + assert details.reason is FlagEvaluationReason.SPLIT + assert details.flag_metadata["reason_code"] == "experiment_split" + assert details.flag_metadata["condition_index"] == 0 + assert details.flag_metadata["rule_type"] == "experiment" + assert details.flag_metadata["rule_id"] == "10000000-0000-4000-8000-000000000001" + assert details.flag_metadata["variant_key"] == "arm_a" + + +def test_details_of_an_experiment_split(client): + response = _build(READERS, {"template": "experiment_split"}) + response["flags"]["garden-layout"]["value"] = True + snapshot = _evaluate(client, response) + + details = snapshot.get_boolean_details("garden-layout", False) + + assert details == FlagEvaluationDetails( + key="garden-layout", + value=True, + reason=FlagEvaluationReason.SPLIT, + variant="arm_a", + flag_metadata={ + "id": 201, + "version": 7, + "config_version": 2, + "reason_code": "experiment_split", + "condition_index": 0, + "rule_type": "experiment", + "rule_id": "10000000-0000-4000-8000-000000000001", + "variant_key": "arm_a", + }, + ) + assert str(details.reason) == "SPLIT" + + +@pytest.mark.parametrize( + "template, reason", + [ + ("targeting_match_targeted_release", FlagEvaluationReason.TARGETING_MATCH), + ("targeting_match_percentage_rollout", FlagEvaluationReason.TARGETING_MATCH), + ("rollout_miss_percentage_rollout", FlagEvaluationReason.DEFAULT), + ("rollout_miss_experiment", FlagEvaluationReason.DEFAULT), + ("experiment_paused", FlagEvaluationReason.DEFAULT), + ("holdout", FlagEvaluationReason.DEFAULT), + ("no_rule_match", FlagEvaluationReason.DEFAULT), + ("missing_group_key", FlagEvaluationReason.ERROR), + ("dependency_error", FlagEvaluationReason.ERROR), + ("error", FlagEvaluationReason.ERROR), + ("v1", FlagEvaluationReason.TARGETING_MATCH), + ], +) +def test_reason_is_the_openfeature_reason_of_the_reason_code(client, template, reason): + snapshot = _evaluate(client, _build(READERS, {"template": template})) + record = _build(READERS, {"template": template})["flags"]["garden-layout"] + + details = snapshot.get_string_details("garden-layout", "default") + + assert details.reason is reason + assert details.flag_metadata["reason_code"] == record["reason"]["code"] + + +def test_an_unknown_config_version_2_reason_code_is_unknown(client): + response = _typed_response({"string-flag": "compact"}) + response["flags"]["string-flag"]["reason"]["code"] = "future_reason" + snapshot = _evaluate(client, response) + + details = snapshot.get_string_details("string-flag", "default") + + assert (details.value, details.reason) == ("compact", FlagEvaluationReason.UNKNOWN) + + +def test_details_of_a_null_flag_default(client): + response = _build(READERS, {"template": "no_rule_match"}) + response["flags"]["garden-layout"]["value"] = None + snapshot = _evaluate(client, response) + + details = snapshot.get_string_details("garden-layout", "Welcome") + + assert details.value == "Welcome" + assert details.reason is FlagEvaluationReason.DEFAULT + assert details.error_code is None + assert details.variant is None + assert details.flag_metadata["reason_code"] == "no_rule_match" + + +def test_a_type_mismatch_keeps_the_metadata_and_drops_the_variant(client): + snapshot = _evaluate(client, _build(READERS, {"template": "v1"})) + + details = snapshot.get_boolean_details("garden-layout", False) + + assert details.value is False + assert details.reason is FlagEvaluationReason.ERROR + assert details.error_code is FlagEvaluationErrorCode.TYPE_MISMATCH + assert details.error_message == ( + "Flag 'garden-layout' has a string value, not a boolean value." + ) + assert details.variant is None + assert details.flag_metadata["config_version"] == 1 + assert details.flag_metadata["reason_code"] == "condition_match" + assert details.flag_metadata["variant_key"] == "compact" + + +def test_an_error_drops_the_variant(client): + response = _build(READERS, {"template": "experiment_split"}) + response["flags"]["garden-layout"]["failed"] = True + snapshot = _evaluate(client, response) + + details = snapshot.get_boolean_details("garden-layout", True) + + assert details.error_code is FlagEvaluationErrorCode.GENERAL + assert details.variant is None + assert details.flag_metadata["variant_key"] == "arm_a" + + +def test_the_integer_accessor_rejects_a_fractional_number(client): + snapshot = _evaluate(client, _typed_response({"ratio": 0.25, "count": 2.0})) + + details = snapshot.get_integer_details("ratio", 1) + + assert details.value == 1 + assert details.error_code is FlagEvaluationErrorCode.TYPE_MISMATCH + assert details.error_message == ( + "Flag 'ratio' has the number 0.25, which is not an integer." + ) + assert snapshot.get_float_value("ratio", 1.0) == 0.25 + count = snapshot.get_integer_value("count", 0) + assert (count, type(count)) == (2, int) + + +@pytest.mark.parametrize( + "value, expected", + [ + (3, 3), + (3.0, 3), + (-0.0, 0), + (2**53 - 1, 2**53 - 1), + (-(2**53 - 1), -(2**53 - 1)), + (2**53, None), + (-(2**53), None), + (2**53 + 1, None), + (-(2**53 + 1), None), + (1e21, None), + ], +) +def test_the_integer_accessor_accepts_only_exact_integers(client, value, expected): + snapshot = _evaluate(client, _typed_response({"n": value})) + + details = snapshot.get_integer_details("n", 7) + + if expected is None: + assert details.value == 7 + assert details.error_code is FlagEvaluationErrorCode.TYPE_MISMATCH + assert "outside the integer range" in details.error_message + # The number accessor still returns it, as the nearest double. + assert snapshot.get_number_value("n", 7) == float(value) + else: + assert (details.value, type(details.value)) == (expected, int) + assert details.error_code is None + + +@pytest.mark.parametrize("sign", [1, -1]) +@pytest.mark.parametrize( + "magnitude, number", + [ + (2**53 - 1, 2**53 - 1), + (2**53, 2.0**53), + # 2**53 + 1 has no double. The nearest one is 2**53. + (2**53 + 1, 2.0**53), + ], +) +def test_the_number_accessor_reads_integers_beyond_the_safe_range_as_doubles( + client, sign, magnitude, number +): + value = sign * magnitude + snapshot = _evaluate(client, _typed_response({"n": value})) + + details = snapshot.get_number_details("n", 7) + + assert details.error_code is None + assert (details.value, type(details.value)) == (sign * number, type(number)) + assert snapshot.get_number_value("n", 7) == sign * number + # The float accessor reads every integer as a double. + assert snapshot.get_float_value("n", 7.0) == float(value) + + +@pytest.mark.parametrize("sign", [1, -1]) +def test_an_integer_beyond_the_range_of_a_double_reads_as_an_infinity(client, sign): + snapshot = _evaluate(client, _typed_response({"n": sign * 10**400})) + + for method in ("get_number_details", "get_float_details"): + details = getattr(snapshot, method)("n", 7.0) + assert details.error_code is None, method + assert details.value == sign * math.inf, method + assert snapshot.get_integer_details("n", 7).error_code is ( + FlagEvaluationErrorCode.TYPE_MISMATCH + ) + + +def test_a_returned_object_cannot_change_the_snapshot(client): + snapshot = _evaluate(client, _typed_response(TYPED_VALUES)) + + snapshot.get_object_value("object-flag", {})["nested"]["on"] = False + + assert snapshot.get_object_value("object-flag", {}) == TYPED_VALUES["object-flag"] + assert snapshot.get_flag_payload("object-flag") == TYPED_VALUES["object-flag"] + + +def test_an_object_too_deeply_nested_to_copy_reads_as_the_default(client): + # Deep enough that copying it exceeds the recursion limit, but shallow enough + # that every supported Python can decode and encode it as JSON. + value: Dict[str, Any] = {} + deep = value + for _ in range(700): + deep["nested"] = {} + deep = deep["nested"] + response = _typed_response({"deep-flag": value, "object-flag": {"on": True}}) + # Not through _evaluate, which copies the response. + with mock.patch("posthog.client.flags", return_value=response): + snapshot = client.evaluate_flags(DISTINCT_ID) + + details = snapshot.get_object_details("deep-flag", {"fallback": True}) + + assert details.value == {"fallback": True} + assert details.error_code is FlagEvaluationErrorCode.PARSE_ERROR + assert details.reason is FlagEvaluationReason.ERROR + assert snapshot.get_object_value("deep-flag", {}) == {} + assert snapshot.get_object_value("object-flag", {}) == {"on": True} + + def _old_server_response(**records: Dict[str, Any]) -> Dict[str, Any]: return { "flags": { @@ -164,6 +534,29 @@ def _old_server_response(**records: Dict[str, Any]) -> Dict[str, Any]: } +def test_a_number_flag_on_an_older_server_is_a_type_mismatch_that_names_the_cause( + client, +): + response = _old_server_response( + **{ + "upload-limit": { + "enabled": True, + "variant": None, + "metadata": {"id": 1, "version": 1, "payload": "25"}, + } + } + ) + snapshot = _evaluate(client, response) + + details = snapshot.get_number_details("upload-limit", 10) + + assert details.value == 10 + assert details.error_code is FlagEvaluationErrorCode.TYPE_MISMATCH + assert details.flag_metadata["config_version"] == 1 + assert "server that does not send typed values" in (details.error_message or "") + assert snapshot.get_flag_payload("upload-limit") == 25 + + @pytest.mark.parametrize("shape", ["v4", "older"]) def test_one_log_per_client_for_records_without_typed_values(shape, caplog): if shape == "v4": @@ -199,16 +592,189 @@ async def test_the_async_client_logs_records_without_typed_values_once(caplog): snapshot = await client.evaluate_flags(DISTINCT_ID) await client.shutdown() - assert snapshot.get_flag("flag") is True + assert snapshot.get_boolean_value("flag", False) is True logged = [r for r in caplog.records if "no typed flag values" in r.getMessage()] assert len(logged) == 1 +def _local_client(versions: Optional[Dict[str, int]] = None) -> Client: + """A client with local definitions; ``versions`` sets a definition's version.""" + client = Client(FAKE_TEST_API_KEY, secret_key="test", send=False) + groups = [{"properties": [], "rollout_percentage": 100}] + definitions: List[Dict[str, Any]] = [ + { + "id": 11, + "key": "legacy-layout", + "active": True, + "filters": { + "groups": groups, + "multivariate": { + "variants": [{"key": "compact", "rollout_percentage": 100}] + }, + }, + }, + {"id": 12, "key": "beta", "active": True, "filters": {"groups": groups}}, + {"id": 13, "key": "off", "active": False, "filters": {"groups": groups}}, + { + "id": 14, + "key": "no-match", + "active": True, + "filters": {"groups": [{"properties": [], "rollout_percentage": 0}]}, + }, + ] + for definition in definitions: + if versions and definition["key"] in versions: + definition["version"] = versions[definition["key"]] + client.feature_flags = definitions + return client + + +@mock.patch("posthog.client.flags") +def test_locally_evaluated_flags_have_a_derived_reason_and_config_version_1( + patch_flags, +): + client = _local_client() + try: + snapshot = client.evaluate_flags("user-123") + legacy_layout = snapshot.get_string_details("legacy-layout", "grid") + beta = snapshot.get_boolean_details("beta", False) + off = snapshot.get_boolean_details("off", True) + no_match = snapshot.get_boolean_details("no-match", True) + mismatch = snapshot.get_number_details("legacy-layout", 1) + finally: + client.shutdown() + + patch_flags.assert_not_called() + assert legacy_layout == FlagEvaluationDetails( + key="legacy-layout", + value="compact", + reason=FlagEvaluationReason.TARGETING_MATCH, + variant="compact", + flag_metadata={"id": 11, "config_version": 1, "variant_key": "compact"}, + ) + assert (beta.value, beta.reason) == (True, FlagEvaluationReason.TARGETING_MATCH) + assert beta.flag_metadata == {"id": 12, "config_version": 1} + assert (off.value, off.reason) == (False, FlagEvaluationReason.DISABLED) + assert off.flag_metadata == { + "id": 13, + "config_version": 1, + "reason_code": "flag_disabled", + } + assert (no_match.value, no_match.reason) == (False, FlagEvaluationReason.DEFAULT) + assert mismatch.error_code is FlagEvaluationErrorCode.TYPE_MISMATCH + assert "config version 1" in (mismatch.error_message or "") + + +@mock.patch("posthog.client.flags") +def test_locally_evaluated_details_carry_the_definition_version(patch_flags): + client = _local_client(versions={"legacy-layout": 4, "beta": 6}) + try: + with mock.patch.object(client, "capture") as capture: + snapshot = client.evaluate_flags("user-123") + legacy_layout = snapshot.get_string_details("legacy-layout", "grid") + beta = snapshot.get_boolean_details("beta", False) + beta_value = snapshot.get_boolean_value("beta", False) + off = snapshot.get_boolean_details("off", True) + finally: + client.shutdown() + + patch_flags.assert_not_called() + assert (legacy_layout.value, beta.value, beta_value) == ("compact", True, True) + assert legacy_layout.flag_metadata == { + "id": 11, + "version": 4, + "config_version": 1, + "variant_key": "compact", + } + assert beta.flag_metadata == {"id": 12, "version": 6, "config_version": 1} + # A definition without a version leaves it out. + assert "version" not in off.flag_metadata + # The flag-called event of the same read reports the same version. + event_versions = { + call.kwargs["properties"]["$feature_flag"]: call.kwargs["properties"].get( + "$feature_flag_version" + ) + for call in capture.call_args_list + } + assert event_versions == {"legacy-layout": 4, "beta": 6, "off": None} + + +def test_missing_flag_returns_the_default_with_flag_not_found(client): + snapshot = _evaluate(client, _typed_response({"boolean-flag": True})) + + details = snapshot.get_number_details("missing-flag", 3) + + assert details == FlagEvaluationDetails( + key="missing-flag", + value=3, + reason=FlagEvaluationReason.ERROR, + error_code=FlagEvaluationErrorCode.FLAG_NOT_FOUND, + error_message="Flag 'missing-flag' is not in the evaluation.", + ) + + +def test_an_unresolved_flag_is_not_found_and_names_the_cause(): + snapshot = FeatureFlagEvaluations( + host=mock.MagicMock(), + distinct_id="user-123", + flags={}, + unresolved_flags={"checkout": UnresolvedFlagReason.EXPERIENCE_CONTINUITY}, + ) + + details = snapshot.get_string_details("checkout", "x") + + assert details == FlagEvaluationDetails( + key="checkout", + value="x", + reason=FlagEvaluationReason.ERROR, + error_code=FlagEvaluationErrorCode.FLAG_NOT_FOUND, + error_message=( + "Local evaluation could not resolve flag 'checkout' " + "(experience_continuity)." + ), + ) + + +@pytest.mark.parametrize( + "template, error_code, message", + [ + ("dependency_error", FlagEvaluationErrorCode.GENERAL, None), + ("error", FlagEvaluationErrorCode.GENERAL, None), + ("missing_group_key", FlagEvaluationErrorCode.INVALID_CONTEXT, None), + ], +) +def test_error_records_return_the_default(client, template, error_code, message): + snapshot = _evaluate(client, _build(RESPONSES, {"template": template})) + + details = snapshot.get_boolean_details("garden-layout", True) + + assert details.value is True + assert details.error_code is error_code + assert details.error_message == message + + +def test_failed_record_uses_the_reason_description_as_the_error_message(client): + response = _build(RESPONSES, {"template": "dependency_error"}) + response["flags"]["garden-layout"]["reason"]["description"] = ( + "Dependency 'garden-root' could not be resolved" + ) + snapshot = _evaluate(client, response) + + details = snapshot.get_string_details("garden-layout", "default") + + assert details.value == "default" + assert details.error_code is FlagEvaluationErrorCode.GENERAL + assert details.error_message == "Dependency 'garden-root' could not be resolved" + # The legacy rendering of a failed record is unchanged: disabled. + assert snapshot.get_flag("garden-layout") is False + + def test_a_failed_record_never_renders_its_value(client): response = _build(RESPONSES, {"template": "error"}) response["flags"]["garden-layout"]["value"] = "compact" snapshot = _evaluate(client, response) + assert snapshot.get_string_value("garden-layout", "default") == "default" assert snapshot.get_flag("garden-layout") is False assert snapshot.is_enabled("garden-layout", True) is False @@ -272,18 +838,20 @@ def test_a_known_field_with_the_wrong_type_fails_only_that_record(path, value): flags = normalize_flags_response(response)["flags"] malformed = flags["garden-layout"] - assert malformed._failed is True - assert malformed._value is None + assert malformed.error_code is FlagEvaluationErrorCode.PARSE_ERROR + assert malformed.failed is True + assert malformed.value is None assert (malformed.enabled, malformed.variant, malformed.get_value()) == ( False, None, False, ) + assert path.split(".")[0] in (malformed.error_message or "") # The record keeps that it could not be read, apart from a server failure. assert malformed._malformed_field == path sibling = flags["sibling"] - assert sibling._failed is False - assert sibling._value == "compact" + assert sibling.error_code is None + assert sibling.value == "compact" assert sibling._malformed_field is None @@ -292,9 +860,8 @@ def test_a_record_that_is_not_an_object_fails_only_that_record(): flags = normalize_flags_response(response)["flags"] - assert flags["broken"]._failed is True - assert flags["broken"].get_value() is False - assert flags["sibling"]._value == "compact" + assert flags["broken"].error_code is FlagEvaluationErrorCode.PARSE_ERROR + assert flags["sibling"].value == "compact" def test_unknown_fields_and_null_optional_fields_are_ignored(): @@ -307,10 +874,10 @@ def test_unknown_fields_and_null_optional_fields_are_ignored(): "garden-layout" ] - assert flag._failed is False - assert flag._value == "compact" + assert flag.error_code is None + assert flag.value == "compact" assert isinstance(flag.metadata, FlagMetadata) - assert flag.metadata._variant_key is None + assert flag.metadata.variant_key is None @pytest.mark.parametrize( @@ -332,8 +899,8 @@ def test_experiment_and_holdout_ids_in_the_metadata_are_ignored(field, value): "garden-layout" ] - assert flag._failed is False - assert flag._value == "compact" + assert flag.error_code is None + assert flag.value == "compact" assert isinstance(flag.metadata, FlagMetadata) assert not hasattr(flag.metadata, field) @@ -361,15 +928,15 @@ def test_a_record_without_value_is_read_as_an_older_server_record(): "garden-layout" ] - assert flag._value == "compact" + assert flag.value == "compact" assert isinstance(flag.metadata, FlagMetadata) - assert flag.metadata._config_version == 1 - assert flag.metadata._rule_type is None - assert flag.metadata._rule_id is None - assert flag.metadata._variant_key is None + assert flag.metadata.config_version == 1 + assert flag.metadata.rule_type is None + assert flag.metadata.rule_id is None + assert flag.metadata.variant_key is None -def test_a_failed_older_server_record_is_kept_as_failed(): +def test_a_failed_older_server_record_returns_the_default(): record = { "key": "garden-layout", "enabled": False, @@ -387,9 +954,8 @@ def test_a_failed_older_server_record_is_kept_as_failed(): "garden-layout" ] - assert flag._failed is True - assert flag._value is False - assert flag.reason is not None and flag.reason.description == "Timed out" + assert flag.error_code is FlagEvaluationErrorCode.GENERAL + assert flag.error_message == "Timed out" def _older_record(**overrides: Any) -> Dict[str, Any]: @@ -441,9 +1007,9 @@ def test_one_unreadable_older_server_record_keeps_its_siblings(): flags = normalize_flags_response(response)["flags"] - assert flags["bad"]._failed is True + assert flags["bad"].failed is True assert flags["bad"]._malformed_field == "reason" - assert flags["good"]._failed is False + assert flags["good"].failed is False assert flags["good"].get_value() is True @@ -462,8 +1028,12 @@ def test_a_value_too_deeply_nested_to_encode_fails_only_that_record(): flags = normalize_flags_response(response)["flags"] - assert flags["deep"]._failed is True + assert flags["deep"].failed is True assert flags["deep"]._malformed_field == "value" + assert flags["deep"].error_code is FlagEvaluationErrorCode.PARSE_ERROR + assert flags["deep"].error_message == ( + "The flag record's value is nested too deeply to read." + ) assert flags["sibling"].get_value() == "compact" @@ -501,9 +1071,9 @@ def test_a_known_field_with_the_wrong_type_fails_only_that_older_server_record( malformed = flags["garden-layout"] assert malformed.key == "garden-layout" - assert malformed._failed is True + assert malformed.failed is True assert malformed._malformed_field == path - assert malformed._value is None + assert malformed.value is None assert (malformed.enabled, malformed.variant, malformed.get_value()) == ( False, None, @@ -516,7 +1086,7 @@ def test_a_known_field_with_the_wrong_type_fails_only_that_older_server_record( {"flags": {"sibling": _older_record(key="sibling")}} )["flags"]["sibling"] ) - assert (sibling._failed, sibling._value, sibling._malformed_field) == ( + assert (sibling.failed, sibling.value, sibling._malformed_field) == ( False, "compact", None, @@ -537,15 +1107,13 @@ def test_a_valid_older_server_record_renders_as_before(): condition_index=0, description="Matched condition set 1", ), - metadata=_set_private_fields( - FlagMetadata( - id=202, - payload=None, - version=9, - description="Garden layout", - has_experiment=False, - ), - _config_version=1, + metadata=FlagMetadata( + id=202, + payload=None, + version=9, + description="Garden layout", + has_experiment=False, + config_version=1, ), ) @@ -562,9 +1130,9 @@ def test_a_record_key_of_the_wrong_type_fails_only_that_record(make_record): )["flags"] assert flags["garden-layout"].key == "garden-layout" - assert flags["garden-layout"]._failed is True + assert flags["garden-layout"].failed is True assert flags["garden-layout"]._malformed_field == "key" - assert flags["sibling"]._failed is False + assert flags["sibling"].failed is False @pytest.mark.parametrize("make_record", [_record, _older_record]) @@ -579,8 +1147,8 @@ def test_a_record_is_read_under_its_map_key(make_record, key): ] assert flag.key == "garden-layout" - assert flag._failed is False - assert flag._value == "compact" + assert flag.failed is False + assert flag.value == "compact" def test_a_server_failed_record_is_not_marked_unreadable(): @@ -596,7 +1164,7 @@ def test_a_server_failed_record_is_not_marked_unreadable(): )["flags"] for flag in flags.values(): - assert flag._failed is True + assert flag.failed is True assert flag._malformed_field is None @@ -664,10 +1232,67 @@ def test_a_config_version_1_payload_is_kept(): assert flags["string-flag"].metadata.payload == '{"columns": 2}' +def test_typed_details_of_unreadable_and_failed_older_server_records(client): + response = { + "flags": { + "unreadable": _older_record(key="unreadable", reason="oops"), + "failed": _older_record( + key="failed", + enabled=False, + variant=None, + failed=True, + **{"reason.description": "Timed out"}, + ), + "healthy": _older_record(key="healthy"), + }, + "errorsWhileComputingFlags": True, + } + snapshot = _evaluate(client, response) + + unreadable = snapshot.get_string_details("unreadable", "x") + assert (unreadable.value, unreadable.error_code) == ( + "x", + FlagEvaluationErrorCode.PARSE_ERROR, + ) + assert unreadable.error_message is not None + assert "reason" in unreadable.error_message + failed = snapshot.get_string_details("failed", "x") + assert (failed.value, failed.error_code, failed.error_message) == ( + "x", + FlagEvaluationErrorCode.GENERAL, + "Timed out", + ) + healthy = snapshot.get_string_details("healthy", "x") + assert (healthy.value, healthy.error_code) == ("compact", None) + + +@pytest.mark.parametrize("make_record", [_record, _older_record]) +def test_typed_details_leave_out_an_absent_flag_id_and_version(make_record, client): + record = make_record() + del record["metadata"]["id"] + del record["metadata"]["version"] + snapshot = _evaluate(client, {"flags": {"garden-layout": record}}) + + details = snapshot.get_string_details("garden-layout", "x") + + assert details.value == "compact" + assert "id" not in details.flag_metadata + assert "version" not in details.flag_metadata + + +def test_a_number_value_wins_over_a_payload_the_record_sends(client): + response = _typed_response({"upload-limit": 25}) + response["flags"]["upload-limit"]["metadata"]["payload"] = "999" + snapshot = _evaluate(client, response) + + assert snapshot.get_number_value("upload-limit", 10) == 25 + assert snapshot.get_flag_payload("upload-limit") == 25 + + def test_feature_flag_value_defaults_to_the_legacy_value(): - assert FeatureFlag.from_value_and_payload("a", "control", None)._value == "control" - assert FeatureFlag.from_value_and_payload("a", True, None)._value is True - assert FeatureFlag.from_value_and_payload("a", False, None)._value is False + assert FeatureFlag.from_value_and_payload("a", "control", None).value == "control" + assert FeatureFlag.from_value_and_payload("a", True, None).value is True + assert FeatureFlag.from_value_and_payload("a", False, None).value is False # Legacy accessor parity: a v4 record and the equivalent older-server record must give @@ -1000,6 +1625,261 @@ def test_feature_flag_called_properties_are_unchanged_for_a_v1_flag(client): ) +def test_a_type_mismatch_is_an_access_that_sends_no_event(client): + with mock.patch.object(client, "capture") as capture: + snapshot = _evaluate(client, _typed_response({"string-flag": "compact"})) + snapshot.get_number_details("string-flag", 0) + + capture.assert_not_called() + assert snapshot.only_accessed().keys == ["string-flag"] + + snapshot.get_flag("string-flag") + + capture.assert_called_once() + properties = capture.call_args.kwargs["properties"] + assert properties["$feature_flag_response"] == "compact" + # A type mismatch is not an evaluation error. + assert "$feature_flag_error" not in properties + + +# The flag each typed accessor reads in the per-read event option tests, and the +# value it returns. +TYPED_READS: Dict[str, Tuple[str, Any]] = { + "boolean": ("boolean-flag", True), + "string": ("string-flag", "compact"), + "number": ("float-flag", 12.5), + "integer": ("integer-flag", 7), + "float": ("float-flag", 12.5), + "object": ("object-flag", TYPED_VALUES["object-flag"]), +} +TYPED_METHODS = [ + f"get_{value_type}_{form}" + for value_type in TYPED_READS + for form in ("value", "details") +] + + +@contextlib.asynccontextmanager +async def _typed_snapshot( + client_kind: str, +) -> AsyncIterator[Tuple[FeatureFlagEvaluations, mock.MagicMock]]: + """Yield a snapshot of ``TYPED_VALUES`` from the sync or async client, and the + mocked ``capture`` of that client.""" + response = _typed_response(TYPED_VALUES) + if client_kind == "sync": + client = Client(FAKE_TEST_API_KEY, send=False) + try: + with mock.patch.object(client, "capture") as capture: + yield _evaluate(client, response), capture + finally: + client.shutdown() + return + async_client = AsyncPosthog("project-key", send=False) + try: + with ( + mock.patch( + "posthog.async_client._async_flags", + new=mock.AsyncMock(return_value=response), + ), + mock.patch.object(async_client, "capture") as capture, + ): + yield await async_client.evaluate_flags(DISTINCT_ID), capture + finally: + await async_client.shutdown() + + +def _typed_read(snapshot: FeatureFlagEvaluations, method: str, **options: Any) -> Any: + """Read ``method``'s flag from ``TYPED_READS`` and return the value it resolved.""" + value_type = method.split("_")[1] + key, _ = TYPED_READS[value_type] + result = getattr(snapshot, method)(key, DEFAULTS[value_type], **options) + return result.value if method.endswith("_details") else result + + +def _flag_called_keys(capture: mock.MagicMock) -> List[str]: + return [ + call.kwargs["properties"]["$feature_flag"] + for call in capture.call_args_list + if call.args == ("$feature_flag_called",) + ] + + +@pytest.mark.parametrize("client_kind", ["sync", "async"]) +@pytest.mark.parametrize("method", TYPED_METHODS) +async def test_a_typed_read_with_events_off_sends_no_event_and_reserves_no_dedupe_key( + method, client_kind +): + key, expected = TYPED_READS[method.split("_")[1]] + async with _typed_snapshot(client_kind) as (snapshot, capture): + suppressed = _typed_read(snapshot, method, send_feature_flag_events=False) + assert suppressed == expected + assert _flag_called_keys(capture) == [] + # A suppressed read is still an access. + assert snapshot.only_accessed().keys == [key] + + later = _typed_read(snapshot, method) + + assert later == expected + assert _flag_called_keys(capture) == [key] + + +@pytest.mark.parametrize("client_kind", ["sync", "async"]) +@pytest.mark.parametrize("method", TYPED_METHODS) +async def test_a_typed_read_sends_one_event_by_default(method, client_kind): + key, expected = TYPED_READS[method.split("_")[1]] + async with _typed_snapshot(client_kind) as (snapshot, capture): + first = _typed_read(snapshot, method) + assert _flag_called_keys(capture) == [key] + again = _typed_read(snapshot, method, send_feature_flag_events=True) + + assert first == again == expected + assert _flag_called_keys(capture) == [key] + + +# A flag of another type for each typed accessor. +MISMATCHED_READS: Dict[str, str] = { + "boolean": "string-flag", + "string": "boolean-flag", + "number": "object-flag", + "integer": "float-flag", + "float": "string-flag", + "object": "integer-flag", +} + + +def _error_logs(caplog: pytest.LogCaptureFixture) -> List[str]: + """The SDK's error logs, without the prefix the SDK adds to each.""" + return [ + record.getMessage().removeprefix("[PostHog] ") + for record in caplog.records + if record.name == "posthog" and record.levelname == "ERROR" + ] + + +@pytest.mark.parametrize("client_kind", ["sync", "async"]) +@pytest.mark.parametrize("method", TYPED_METHODS) +async def test_a_typed_read_of_the_wrong_type_sends_no_event_and_logs_once( + method, client_kind, caplog +): + value_type = method.split("_")[1] + key = MISMATCHED_READS[value_type] + default = DEFAULTS[value_type] + async with _typed_snapshot(client_kind) as (snapshot, capture): + with caplog.at_level("ERROR", logger="posthog"): + results = [getattr(snapshot, method)(key, default) for _ in range(2)] + details = getattr(snapshot, f"get_{value_type}_details")(key, default) + + assert _flag_called_keys(capture) == [] + # The read still counts as an access. + assert snapshot.only_accessed().keys == [key] + + values = [r.value if method.endswith("_details") else r for r in results] + assert values == [default, default] + assert details.error_code is FlagEvaluationErrorCode.TYPE_MISMATCH + assert _error_logs(caplog) == [details.error_message] + + +@pytest.mark.parametrize("client_kind", ["sync", "async"]) +@pytest.mark.parametrize( + "later_read", + [ + lambda snapshot: snapshot.get_string_value("string-flag", "default"), + lambda snapshot: snapshot.get_string_details("string-flag", "default"), + lambda snapshot: snapshot.get_flag("string-flag"), + lambda snapshot: snapshot.is_enabled("string-flag"), + ], + ids=["get_string_value", "get_string_details", "get_flag", "is_enabled"], +) +async def test_a_read_after_a_type_mismatch_reports_the_flag_once( + later_read, client_kind +): + async with _typed_snapshot(client_kind) as (snapshot, capture): + snapshot.get_boolean_value("string-flag", False) + snapshot.get_number_details("string-flag", 0) + assert _flag_called_keys(capture) == [] + + later_read(snapshot) + later_read(snapshot) + + assert _flag_called_keys(capture) == ["string-flag"] + properties = capture.call_args.kwargs["properties"] + assert properties["$feature_flag_response"] == "compact" + assert "$feature_flag_error" not in properties + + +def test_each_wrong_type_of_a_flag_is_logged_once_per_client(caplog): + response = _typed_response({"string-flag": "compact"}) + client = Client(FAKE_TEST_API_KEY, send=False) + other_client = Client(FAKE_TEST_API_KEY, send=False) + try: + with caplog.at_level("ERROR", logger="posthog"): + for _ in range(2): + snapshot = _evaluate(client, response) + snapshot.get_boolean_value("string-flag", False) + snapshot.get_number_details("string-flag", 0) + snapshot.get_boolean_details( + "string-flag", False, send_feature_flag_events=False + ) + _evaluate(other_client, response).get_boolean_value("string-flag", False) + finally: + client.shutdown() + other_client.shutdown() + + assert _error_logs(caplog) == [ + "Flag 'string-flag' has a string value, not a boolean value.", + "Flag 'string-flag' has a string value, not a number value.", + "Flag 'string-flag' has a string value, not a boolean value.", + ] + + +@pytest.mark.parametrize( + "key, value_type, flag_error", + [ + # No value, or false read as another type: the default without an error. + ("null-flag", "number", None), + ("false-flag", "string", None), + ("missing-flag", "boolean", "flag_missing"), + ("failed-flag", "string", None), + ("unreadable-flag", "object", None), + ], +) +def test_a_default_without_a_type_mismatch_still_reports_the_flag( + client, caplog, key, value_type, flag_error +): + response = _typed_response(TYPED_VALUES) + response["flags"]["failed-flag"] = _record( + key="failed-flag", value=None, failed=True + ) + response["flags"]["unreadable-flag"] = _record( + key="unreadable-flag", **{"metadata.id": "202"} + ) + with mock.patch.object(client, "capture") as capture: + snapshot = _evaluate(client, response) + with caplog.at_level("ERROR", logger="posthog"): + details = ACCESSORS[value_type](snapshot)(key, DEFAULTS[value_type]) + + assert details.value == DEFAULTS[value_type] + assert details.error_code is not FlagEvaluationErrorCode.TYPE_MISMATCH + assert _flag_called_keys(capture) == [key] + properties = capture.call_args.kwargs["properties"] + assert properties.get("$feature_flag_error") == flag_error + assert _error_logs(caplog) == [] + + +def test_a_throwing_before_send_does_not_break_a_typed_read(): + calls = [] + + def before_send(event): + calls.append(event["event"]) + raise ValueError("boom") + + client = Client(FAKE_TEST_API_KEY, before_send=before_send, send=False) + snapshot = _evaluate(client, _typed_response({"string-flag": "compact"})) + + assert snapshot.get_string_value("string-flag", "default") == "compact" + assert calls == ["$feature_flag_called"] + + async def test_async_client_keeps_healthy_flags_beside_unreadable_and_sparse_ones(): sparse = _record(key="sparse") del sparse["metadata"]["id"], sparse["metadata"]["version"] @@ -1036,17 +1916,13 @@ async def test_async_client_keeps_healthy_flags_beside_unreadable_and_sparse_one async def test_async_client_reads_the_v4_response(): with mock.patch( "posthog.async_client._async_flags", - new=mock.AsyncMock( - return_value=_typed_response( - {"float-flag": 12.5, "object-flag": {"layout": "compact"}} - ) - ), + new=mock.AsyncMock(return_value=_typed_response(TYPED_VALUES)), ): client = AsyncPosthog("project-key", send=False) snapshot = await client.evaluate_flags(DISTINCT_ID) await client.shutdown() - assert snapshot.get_flag("object-flag") is True - assert snapshot.get_flag_payload("object-flag") == {"layout": "compact"} + assert snapshot.get_object_value("object-flag", {}) == TYPED_VALUES["object-flag"] + assert snapshot.get_number_value("float-flag", 0) == 12.5 assert snapshot.get_flag("float-flag") is True assert snapshot.get_flag_payload("float-flag") == 12.5 diff --git a/posthog/types.py b/posthog/types.py index bc1dee73..e3845043 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -1,3 +1,4 @@ +import copy import json import logging import math @@ -7,6 +8,7 @@ Any, Callable, Dict, + Generic, List, Optional, TypedDict, @@ -17,6 +19,8 @@ FlagValue = Union[bool, str] +_T = TypeVar("_T") + def _reject_json_constant(value: str) -> None: raise ValueError("Invalid JSON constant") @@ -181,6 +185,13 @@ class FlagMetadata: description: Feature flag description. has_experiment: Whether the flag has a linked experiment. ``None`` when the server does not report the field (older deployments). + config_version: Configuration format of the flag. Responses without a + typed ``value`` always read as ``1``. + rule_type: Type of the rule that produced the result, when one did. + rule_id: ID of the rule that produced the result, when one did. + variant_key: Key of the assigned variant, when one was assigned. + forced_variant: Whether a condition-level variant override produced the + result. """ id: Optional[int] @@ -188,16 +199,11 @@ class FlagMetadata: version: Optional[int] description: str has_experiment: Optional[bool] = None - # Work in progress: the ``/flags?v=4`` metadata fields are private until the - # typed flag value accessors ship, which make them public. ``_config_version`` - # is the configuration format of the flag (``1`` for a record without a typed - # ``value``); the rule fields name the rule that produced the result, and the - # variant fields the assigned variant. - _config_version: Optional[int] = field(default=None, init=False) - _rule_type: Optional[str] = field(default=None, init=False) - _rule_id: Optional[str] = field(default=None, init=False) - _variant_key: Optional[str] = field(default=None, init=False) - _forced_variant: Optional[bool] = field(default=None, init=False) + config_version: Optional[int] = None + rule_type: Optional[str] = None + rule_id: Optional[str] = None + variant_key: Optional[str] = None + forced_variant: Optional[bool] = None @classmethod def from_json(cls, resp: Any) -> Union["FlagMetadata", LegacyFlagMetadata]: @@ -209,7 +215,7 @@ def from_json(cls, resp: Any) -> Union["FlagMetadata", LegacyFlagMetadata]: if not resp: return LegacyFlagMetadata(payload=None) raw_has_experiment = resp.get("has_experiment") - metadata = cls( + return cls( id=resp.get("id"), payload=resp.get("payload"), version=resp.get("version"), @@ -217,22 +223,57 @@ def from_json(cls, resp: Any) -> Union["FlagMetadata", LegacyFlagMetadata]: has_experiment=raw_has_experiment if isinstance(raw_has_experiment, bool) else None, + config_version=1, ) - return _set_private_fields(metadata, _config_version=1) -_T = TypeVar("_T") +class FlagEvaluationErrorCode(str, Enum): + """Why a typed flag accessor returned the caller default instead of a value. + The values are the OpenFeature error codes. + """ -def _set_private_fields(obj: _T, **fields: Any) -> _T: - """Set the private, work-in-progress fields of a frozen dataclass.""" - for name, value in fields.items(): - object.__setattr__(obj, name, value) - return obj + FLAG_NOT_FOUND = "FLAG_NOT_FOUND" + """The flag is not in the evaluation.""" + PARSE_ERROR = "PARSE_ERROR" + """A field of the flag's record has the wrong JSON type.""" + TYPE_MISMATCH = "TYPE_MISMATCH" + """The flag's value is not of the requested type.""" + INVALID_CONTEXT = "INVALID_CONTEXT" + """The flag needs a group the evaluation did not include.""" + GENERAL = "GENERAL" + """The flag failed to evaluate.""" + + def __str__(self) -> str: + return self.value + + +class FlagEvaluationReason(str, Enum): + """Why a typed flag accessor returned its value. + + The values are the OpenFeature reasons. The PostHog reason code is in + ``flag_metadata["reason_code"]``. + """ + + TARGETING_MATCH = "TARGETING_MATCH" + """The flag matched its targeting.""" + SPLIT = "SPLIT" + """The flag assigned an experiment variant.""" + DEFAULT = "DEFAULT" + """No rule produced a value, so the flag returned its default.""" + DISABLED = "DISABLED" + """The flag is disabled.""" + UNKNOWN = "UNKNOWN" + """The server gave a reason this SDK does not know.""" + ERROR = "ERROR" + """The accessor returned the caller default because of ``error_code``.""" + + def __str__(self) -> str: + return self.value class _DerivedValue: - """Default of ``FeatureFlag._value``: derive the value from ``variant`` and ``enabled``.""" + """Default of ``FeatureFlag.value``: derive the value from ``variant`` and ``enabled``.""" def __repr__(self) -> str: return "" @@ -240,11 +281,19 @@ def __repr__(self) -> str: _DERIVED_VALUE: Any = _DerivedValue() +# Reason codes that carry an error for a config version 2 flag. +_V2_ERROR_REASON_CODES = { + "missing_group_key": FlagEvaluationErrorCode.INVALID_CONTEXT, + "dependency_error": FlagEvaluationErrorCode.GENERAL, + "error": FlagEvaluationErrorCode.GENERAL, +} + @dataclass(frozen=True) class FeatureFlag: """Detailed feature flag evaluation returned by the flags API. + ``value``, ``failed``, ``reason`` and ``metadata`` are the evaluation details. ``enabled``, ``variant`` and ``metadata.payload`` are the value rendered the way responses before ``/flags?v=4`` carried it, which the legacy getters return. @@ -254,6 +303,13 @@ class FeatureFlag: variant: Variant key for multivariate flags, otherwise ``None``. reason: Optional reason metadata explaining the result. metadata: Payload and other metadata returned by the API. + value: The typed flag value: a boolean, string, number, object, or + ``None`` when the flag has no value. Defaults to ``variant``, or + ``enabled`` when there is no variant. + failed: Whether the flag failed to evaluate, or its record could not be + read. + error_code: Why the flag has no usable value, when it has an error. + error_message: Human-readable detail for ``error_code``. """ key: str @@ -261,25 +317,22 @@ class FeatureFlag: variant: Optional[str] reason: Optional[FlagReason] metadata: Union[FlagMetadata, LegacyFlagMetadata] - # Work in progress: private until the typed flag value accessors ship, which - # make them public. ``_value`` is the typed flag value: a boolean, string, - # number, object, or ``None`` when the flag has no value; it defaults to - # ``variant``, or ``enabled`` when there is no variant. ``_failed`` says whether - # the flag failed to evaluate, or its record could not be read. - _value: Any = field(default=_DERIVED_VALUE, init=False) - _failed: bool = field(default=False, init=False) + value: Any = _DERIVED_VALUE + failed: bool = False + error_code: Optional[FlagEvaluationErrorCode] = None + error_message: Optional[str] = None # The wire path of the field that made the record unreadable, such as # ``"metadata.id"``. ``None`` unless the record could not be read; a record the - # server marks failed has ``_failed`` set and this ``None``. + # server marks failed has ``failed`` set and this ``None``. _malformed_field: Optional[str] = field( default=None, init=False, repr=False, compare=False ) def __post_init__(self) -> None: - if self._value is _DERIVED_VALUE: + if self.value is _DERIVED_VALUE: object.__setattr__( self, - "_value", + "value", self.variant if self.variant is not None else self.enabled, ) @@ -341,6 +394,21 @@ def _read_value(value: Any) -> Any: raise _MalformedRecord("value") +def _record_error( + failed: bool, reason: Optional[FlagReason], config_version: Optional[int] +) -> tuple[Optional[FlagEvaluationErrorCode], Optional[str]]: + code = None + if config_version == 2 and reason is not None: + code = _V2_ERROR_REASON_CODES.get(reason.code) + if code is None and failed: + code = FlagEvaluationErrorCode.GENERAL + if code is None: + return None, None + # The reason description is the error message only for a failed record. + message = reason.description if failed and reason and reason.description else None + return code, message + + def _legacy_rendering(value: Any) -> tuple[bool, Optional[str], Optional[str]]: """Render a typed value as ``(enabled, variant, encoded payload)``. @@ -358,7 +426,7 @@ def _parse_typed_record(key: str, resp: Any) -> FeatureFlag: """Parse a ``/flags?v=4`` record, which carries a typed ``value``. Unknown fields are ignored. A known field with the wrong JSON type fails only - this record, which then reads as disabled. An absent or null field reads as absent, + this record, with ``PARSE_ERROR``. An absent or null field reads as absent, except ``value``, where null means the flag has no value. """ try: @@ -406,22 +474,19 @@ def _parse_typed_record(key: str, resp: Any) -> FeatureFlag: has_experiment=_read_typed( raw_metadata.get("has_experiment"), "metadata.has_experiment", bool ), - ) - _set_private_fields( - metadata, - _config_version=_read_int( + config_version=_read_int( raw_metadata.get("config_version"), "metadata.config_version" ), - _rule_type=_read_typed( + rule_type=_read_typed( raw_metadata.get("rule_type"), "metadata.rule_type", str ), - _rule_id=_read_typed( + rule_id=_read_typed( raw_metadata.get("rule_id"), "metadata.rule_id", str ), - _variant_key=_read_typed( + variant_key=_read_typed( raw_metadata.get("variant_key"), "metadata.variant_key", str ), - _forced_variant=_read_typed( + forced_variant=_read_typed( raw_metadata.get("forced_variant"), "metadata.forced_variant", bool ), ) @@ -432,16 +497,295 @@ def _parse_typed_record(key: str, resp: Any) -> FeatureFlag: return _malformed_record(key, str(error)) except RecursionError: # A value nested too deeply to encode as a payload fails only this record. - return _malformed_record(key, "value") + return _malformed_record( + key, "value", "The flag record's value is nested too deeply to read." + ) - flag = FeatureFlag( + config_version = ( + metadata.config_version if isinstance(metadata, FlagMetadata) else None + ) + error_code, error_message = _record_error(failed, reason, config_version) + return FeatureFlag( key=key, enabled=enabled, variant=variant, reason=reason, metadata=metadata, + value=value, + failed=failed, + error_code=error_code, + error_message=error_message, + ) + + +FlagMetadataValue = Union[bool, int, float, str] + + +@dataclass(frozen=True) +class FlagEvaluationDetails(Generic[_T]): + """The result of a typed flag accessor such as ``get_boolean_details``. + + The fields match OpenFeature's evaluation details, so an OpenFeature provider + can pass them through. + + Attributes: + key: Feature flag key. + value: The flag value, or the caller default when the flag has no value + of the requested type. + reason: Why the accessor returned ``value``, as an OpenFeature reason. + variant: Key of the assigned variant, when the accessor returned the flag's + value and the server reported a variant key. + error_code: Why the caller default was returned, when it was returned + because of an error. A flag without a value, or with the value + ``False`` read as another type, returns the default without an error. + error_message: Human-readable detail for ``error_code``. + flag_metadata: The flag's ``id``, ``version``, ``config_version``, + ``reason_code``, ``condition_index``, ``rule_type``, ``rule_id`` and + ``variant_key``, each when the flag record has it. A new dict on every + call. + """ + + key: str + value: _T + reason: FlagEvaluationReason + variant: Optional[str] = None + error_code: Optional[FlagEvaluationErrorCode] = None + error_message: Optional[str] = None + flag_metadata: Dict[str, FlagMetadataValue] = field(default_factory=dict) + + +# Every SDK reads numbers as IEEE-754 doubles, which hold every integer up to this +# magnitude exactly. The integer accessor does not accept larger integers, and the +# number accessor reads them as the nearest double, so all SDKs agree. +_MAX_SAFE_INTEGER = 2**53 - 1 + + +def _read_number(value: Any) -> Any: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return _NOT_ACCEPTED + if isinstance(value, int) and abs(value) > _MAX_SAFE_INTEGER: + return _read_float(value) + return value + + +def _read_integer(value: Any) -> Any: + if isinstance(value, float) and value.is_integer(): + value = int(value) + if ( + isinstance(value, int) + and not isinstance(value, bool) + and abs(value) <= _MAX_SAFE_INTEGER + ): + return value + return _NOT_ACCEPTED + + +def _read_float(value: Any) -> Any: + if isinstance(value, float): + return value + if isinstance(value, int) and not isinstance(value, bool): + try: + return float(value) + except OverflowError: + # The nearest double is an infinity, as JavaScript parses the number. + return math.inf if value > 0 else -math.inf + return _NOT_ACCEPTED + + +_NOT_ACCEPTED: Any = object() +# Returned by a reader for a value of the right type that it cannot read. +_UNREADABLE: Any = object() + + +def _read_object(value: Any) -> Any: + if not isinstance(value, dict): + return _NOT_ACCEPTED + try: + return copy.deepcopy(value) + except RecursionError: + # Too deeply nested to copy. A typed read never throws, so the flag reads + # as unreadable instead. + return _UNREADABLE + + +# Reads a flag value as one accessor type, or returns ``_NOT_ACCEPTED`` or +# ``_UNREADABLE``. A returned object is a copy, so a caller cannot change the +# evaluated flag. +_FLAG_VALUE_READERS: Dict[str, Callable[[Any], Any]] = { + "boolean": lambda value: value if isinstance(value, bool) else _NOT_ACCEPTED, + "string": lambda value: value if isinstance(value, str) else _NOT_ACCEPTED, + "number": _read_number, + "integer": _read_integer, + "float": _read_float, + "object": _read_object, +} + +# The contract's OpenFeature reasons for config version 2 reason codes that carry +# no error. +_V2_REASONS = { + "targeting_match": FlagEvaluationReason.TARGETING_MATCH, + "experiment_split": FlagEvaluationReason.SPLIT, + "rollout_miss": FlagEvaluationReason.DEFAULT, + "experiment_paused": FlagEvaluationReason.DEFAULT, + "holdout": FlagEvaluationReason.DEFAULT, + "no_rule_match": FlagEvaluationReason.DEFAULT, +} + + +def _flag_reason( + value: Any, reason_code: Optional[str], config_version: Optional[int] +) -> FlagEvaluationReason: + """The OpenFeature reason of a flag record that has no error.""" + if reason_code == "flag_disabled": + return FlagEvaluationReason.DISABLED + if config_version == 2: + return _V2_REASONS.get(reason_code or "", FlagEvaluationReason.UNKNOWN) + # The contract maps no config version 1 reason code, so the reason follows the + # value, as PostHog's OpenFeature providers report it. + if value is True or isinstance(value, str): + return FlagEvaluationReason.TARGETING_MATCH + return FlagEvaluationReason.DEFAULT + + +def _json_type_name(value: Any) -> str: + if isinstance(value, bool): + return "boolean" + if isinstance(value, str): + return "string" + if isinstance(value, dict): + return "object" + return "number" + + +def _with_article(type_name: str) -> str: + return f"an {type_name}" if type_name[0] in "aeiou" else f"a {type_name}" + + +def _type_mismatch_message( + key: str, value: Any, value_type: str, config_version: Optional[int] +) -> str: + actual = _json_type_name(value) + if actual == "number" and value_type == "integer": + if isinstance(value, float) and not value.is_integer(): + return f"Flag '{key}' has the number {value!r}, which is not an integer." + return ( + f"Flag '{key}' has the number {value!r}, which is outside the integer" + f" range of -{_MAX_SAFE_INTEGER} to {_MAX_SAFE_INTEGER}." + ) + message = ( + f"Flag '{key}' has {_with_article(actual)} value," + f" not {_with_article(value_type)} value." + ) + if config_version != 2 and value_type != "boolean" and value_type != "string": + message += ( + " The flag has config version 1, which has number and object values" + " only as a payload. A server that does not send typed values reads" + " every flag this way." + ) + return message + + +def _resolve_flag_value( + key: str, + value: Any, + default_value: _T, + value_type: str, + *, + config_version: Optional[int], + reason_code: Optional[str], + variant: Optional[str], + flag_metadata: Dict[str, FlagMetadataValue], + error_code: Optional[FlagEvaluationErrorCode] = None, + error_message: Optional[str] = None, +) -> FlagEvaluationDetails[_T]: + """Resolve a flag value for a typed accessor without coercing it. + + The caller default is returned for a failed or unreadable record, a ``None`` + value, ``False`` read as another type, and a value of another type. Only the + first and the last carry an error code. + """ + + def resolved( + result: Any, + code: Optional[FlagEvaluationErrorCode] = None, + message: Optional[str] = None, + returned_value: bool = False, + ) -> FlagEvaluationDetails[Any]: + return FlagEvaluationDetails( + key=key, + value=result, + reason=FlagEvaluationReason.ERROR + if code is not None + else _flag_reason(value, reason_code, config_version), + variant=variant if returned_value else None, + error_code=code, + error_message=message, + flag_metadata=dict(flag_metadata), + ) + + if error_code is not None: + return resolved(default_value, error_code, error_message) + result = _FLAG_VALUE_READERS[value_type](value) + if result is _UNREADABLE: + return resolved( + default_value, + FlagEvaluationErrorCode.PARSE_ERROR, + f"Flag '{key}' has an object value nested too deeply to read.", + ) + if result is not _NOT_ACCEPTED: + return resolved(result, returned_value=True) + if value is None or value is False: + return resolved(default_value) + return resolved( + default_value, + FlagEvaluationErrorCode.TYPE_MISMATCH, + _type_mismatch_message(key, value, value_type, config_version), + ) + + +def _resolve_flag_details( + key: str, flag: Optional[FeatureFlag], default_value: _T, value_type: str +) -> FlagEvaluationDetails[_T]: + """Resolve a ``/flags`` record for a typed accessor.""" + if flag is None: + return FlagEvaluationDetails( + key=key, + value=default_value, + reason=FlagEvaluationReason.ERROR, + error_code=FlagEvaluationErrorCode.FLAG_NOT_FOUND, + error_message=f"Flag '{key}' is not in the evaluation.", + ) + metadata = flag.metadata if isinstance(flag.metadata, FlagMetadata) else None + reason = flag.reason + flag_metadata: Dict[str, Any] = {} + if metadata is not None: + flag_metadata.update( + id=metadata.id, + version=metadata.version, + config_version=metadata.config_version, + ) + if reason is not None: + flag_metadata.update( + reason_code=reason.code or None, condition_index=reason.condition_index + ) + if metadata is not None: + flag_metadata.update( + rule_type=metadata.rule_type, + rule_id=metadata.rule_id, + variant_key=metadata.variant_key, + ) + return _resolve_flag_value( + key, + flag.value, + default_value, + value_type, + config_version=metadata.config_version if metadata else None, + reason_code=reason.code if reason else None, + variant=metadata.variant_key if metadata else None, + flag_metadata={k: v for k, v in flag_metadata.items() if v is not None}, + error_code=flag.error_code, + error_message=flag.error_message, ) - return _set_private_fields(flag, _value=value, _failed=failed) def _parse_older_record(key: str, resp: Dict[str, Any]) -> FeatureFlag: @@ -472,16 +816,21 @@ def _parse_older_record(key: str, resp: Dict[str, Any]) -> FeatureFlag: except _MalformedRecord as error: return _malformed_record(key, str(error)) - flag = FeatureFlag( + reason = FlagReason.from_json(raw_reason) + failed = resp.get("failed") is True + error_code, error_message = _record_error(failed, reason, 1) + return FeatureFlag( key=key, # An older server always sends ``enabled``; a record without it keeps # ``None``, as before. enabled=cast(bool, resp.get("enabled")), variant=resp.get("variant"), - reason=FlagReason.from_json(raw_reason), + reason=reason, metadata=FlagMetadata.from_json(raw_metadata), + failed=failed, + error_code=error_code, + error_message=error_message, ) - return _set_private_fields(flag, _failed=resp.get("failed") is True) def _read_record_key(resp: Dict[str, Any]) -> None: @@ -490,7 +839,9 @@ def _read_record_key(resp: Dict[str, Any]) -> None: _read_typed(resp.get("key"), "key", str) -def _malformed_record(key: str, malformed_field: str) -> FeatureFlag: +def _malformed_record( + key: str, malformed_field: str, message: Optional[str] = None +) -> FeatureFlag: """A record with a known field of the wrong JSON type: failed, and disabled.""" flag = FeatureFlag( key=key, @@ -498,10 +849,14 @@ def _malformed_record(key: str, malformed_field: str) -> FeatureFlag: variant=None, reason=None, metadata=LegacyFlagMetadata(payload=None), + value=None, + failed=True, + error_code=FlagEvaluationErrorCode.PARSE_ERROR, + error_message=message + or f"The flag record's {malformed_field} field has the wrong JSON type.", ) - return _set_private_fields( - flag, _value=None, _failed=True, _malformed_field=malformed_field - ) + object.__setattr__(flag, "_malformed_field", malformed_field) + return flag def _parse_record(key: str, resp: Any) -> FeatureFlag: @@ -529,7 +884,9 @@ def _has_untyped_flag_records(resp: Any) -> bool: _UNTYPED_FLAGS_RESPONSE_MESSAGE = ( "[FEATURE FLAGS] The /flags response has no typed flag values, so the server" - " may not support /flags?v=4. Every flag is read as config version 1." + " may not support /flags?v=4. Typed accessors read every flag as config" + " version 1: number and object values are only available as payloads, and" + " get_number_value and get_object_value return the default." ) diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 841ebabb..6bb9743a 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -21,6 +21,9 @@ alias posthog.FeatureFlagEvaluations -> posthog.feature_flag_evaluations.Feature alias posthog.FeatureFlagResult -> posthog.types.FeatureFlagResult alias posthog.FlagDefinitionCacheData -> posthog.flag_definition_cache.FlagDefinitionCacheData alias posthog.FlagDefinitionCacheProvider -> posthog.flag_definition_cache.FlagDefinitionCacheProvider +alias posthog.FlagEvaluationDetails -> posthog.types.FlagEvaluationDetails +alias posthog.FlagEvaluationErrorCode -> posthog.types.FlagEvaluationErrorCode +alias posthog.FlagEvaluationReason -> posthog.types.FlagEvaluationReason alias posthog.FlagValue -> posthog.types.FlagValue alias posthog.FlagsAndPayloads -> posthog.types.FlagsAndPayloads alias posthog.ID_TYPES -> posthog.args.ID_TYPES @@ -322,6 +325,8 @@ alias posthog.exception_capture.Client -> posthog.client.Client alias posthog.exception_utils.ExcInfo -> posthog.args.ExcInfo alias posthog.exception_utils.ExceptionArg -> posthog.args.ExceptionArg alias posthog.feature_flag_evaluations.FeatureFlag -> posthog.types.FeatureFlag +alias posthog.feature_flag_evaluations.FlagEvaluationDetails -> posthog.types.FlagEvaluationDetails +alias posthog.feature_flag_evaluations.FlagEvaluationErrorCode -> posthog.types.FlagEvaluationErrorCode alias posthog.feature_flag_evaluations.FlagValue -> posthog.types.FlagValue alias posthog.feature_flag_evaluations.UnresolvedFlagReason -> posthog.types.UnresolvedFlagReason alias posthog.feature_flags.FlagValue -> posthog.types.FlagValue @@ -1129,9 +1134,13 @@ attribute posthog.traces = None attribute posthog.types.BeforeSendCallback = Callable[[dict[str, Any]], Optional[dict[str, Any]]] attribute posthog.types.BeforeSpanSendCallback = Callable[[dict[str, Any]], Optional[dict[str, Any]]] attribute posthog.types.FeatureFlag.enabled: bool +attribute posthog.types.FeatureFlag.error_code: Optional[FlagEvaluationErrorCode] = None +attribute posthog.types.FeatureFlag.error_message: Optional[str] = None +attribute posthog.types.FeatureFlag.failed: bool = False attribute posthog.types.FeatureFlag.key: str attribute posthog.types.FeatureFlag.metadata: Union[FlagMetadata, LegacyFlagMetadata] attribute posthog.types.FeatureFlag.reason: Optional[FlagReason] +attribute posthog.types.FeatureFlag.value: Any = _DERIVED_VALUE attribute posthog.types.FeatureFlag.variant: Optional[str] attribute posthog.types.FeatureFlagError.CONNECTION_ERROR = 'connection_error' attribute posthog.types.FeatureFlagError.ERRORS_WHILE_COMPUTING = 'errors_while_computing_flags' @@ -1148,11 +1157,35 @@ attribute posthog.types.FeatureFlagResult.key: str attribute posthog.types.FeatureFlagResult.payload: Optional[Any] attribute posthog.types.FeatureFlagResult.reason: Optional[str] attribute posthog.types.FeatureFlagResult.variant: Optional[str] +attribute posthog.types.FlagEvaluationDetails.error_code: Optional[FlagEvaluationErrorCode] = None +attribute posthog.types.FlagEvaluationDetails.error_message: Optional[str] = None +attribute posthog.types.FlagEvaluationDetails.flag_metadata: Dict[str, FlagMetadataValue] = field(default_factory=dict) +attribute posthog.types.FlagEvaluationDetails.key: str +attribute posthog.types.FlagEvaluationDetails.reason: FlagEvaluationReason +attribute posthog.types.FlagEvaluationDetails.value: _T +attribute posthog.types.FlagEvaluationDetails.variant: Optional[str] = None +attribute posthog.types.FlagEvaluationErrorCode.FLAG_NOT_FOUND = 'FLAG_NOT_FOUND' +attribute posthog.types.FlagEvaluationErrorCode.GENERAL = 'GENERAL' +attribute posthog.types.FlagEvaluationErrorCode.INVALID_CONTEXT = 'INVALID_CONTEXT' +attribute posthog.types.FlagEvaluationErrorCode.PARSE_ERROR = 'PARSE_ERROR' +attribute posthog.types.FlagEvaluationErrorCode.TYPE_MISMATCH = 'TYPE_MISMATCH' +attribute posthog.types.FlagEvaluationReason.DEFAULT = 'DEFAULT' +attribute posthog.types.FlagEvaluationReason.DISABLED = 'DISABLED' +attribute posthog.types.FlagEvaluationReason.ERROR = 'ERROR' +attribute posthog.types.FlagEvaluationReason.SPLIT = 'SPLIT' +attribute posthog.types.FlagEvaluationReason.TARGETING_MATCH = 'TARGETING_MATCH' +attribute posthog.types.FlagEvaluationReason.UNKNOWN = 'UNKNOWN' +attribute posthog.types.FlagMetadata.config_version: Optional[int] = None attribute posthog.types.FlagMetadata.description: str +attribute posthog.types.FlagMetadata.forced_variant: Optional[bool] = None attribute posthog.types.FlagMetadata.has_experiment: Optional[bool] = None attribute posthog.types.FlagMetadata.id: Optional[int] attribute posthog.types.FlagMetadata.payload: Optional[str] +attribute posthog.types.FlagMetadata.rule_id: Optional[str] = None +attribute posthog.types.FlagMetadata.rule_type: Optional[str] = None +attribute posthog.types.FlagMetadata.variant_key: Optional[str] = None attribute posthog.types.FlagMetadata.version: Optional[int] +attribute posthog.types.FlagMetadataValue = Union[bool, int, float, str] attribute posthog.types.FlagReason.code: str attribute posthog.types.FlagReason.condition_index: Optional[int] attribute posthog.types.FlagReason.description: str @@ -1336,11 +1369,14 @@ class posthog.request.GetResponse(data: Any, etag: Optional[str] = None, not_mod class posthog.request.HTTPAdapterWithSocketOptions(*args, socket_options: Optional[SocketOptions] = None, **kwargs) class posthog.request.QuotaLimitError class posthog.tracing.span.Span -class posthog.types.FeatureFlag(key: str, enabled: bool, variant: Optional[str], reason: Optional[FlagReason], metadata: Union[FlagMetadata, LegacyFlagMetadata]) +class posthog.types.FeatureFlag(key: str, enabled: bool, variant: Optional[str], reason: Optional[FlagReason], metadata: Union[FlagMetadata, LegacyFlagMetadata], value: Any = _DERIVED_VALUE, failed: bool = False, error_code: Optional[FlagEvaluationErrorCode] = None, error_message: Optional[str] = None) class posthog.types.FeatureFlagError class posthog.types.FeatureFlagEvaluationRuntime class posthog.types.FeatureFlagResult(key: str, enabled: bool, variant: Optional[str], payload: Optional[Any], reason: Optional[str]) -class posthog.types.FlagMetadata(id: Optional[int], payload: Optional[str], version: Optional[int], description: str, has_experiment: Optional[bool] = None) +class posthog.types.FlagEvaluationDetails(key: str, value: _T, reason: FlagEvaluationReason, variant: Optional[str] = None, error_code: Optional[FlagEvaluationErrorCode] = None, error_message: Optional[str] = None, flag_metadata: Dict[str, FlagMetadataValue] = dict()) +class posthog.types.FlagEvaluationErrorCode +class posthog.types.FlagEvaluationReason +class posthog.types.FlagMetadata(id: Optional[int], payload: Optional[str], version: Optional[int], description: str, has_experiment: Optional[bool] = None, config_version: Optional[int] = None, rule_type: Optional[str] = None, rule_id: Optional[str] = None, variant_key: Optional[str] = None, forced_variant: Optional[bool] = None) class posthog.types.FlagReason(code: str, condition_index: Optional[int], description: str) class posthog.types.FlagsAndPayloads class posthog.types.FlagsResponse @@ -1805,9 +1841,21 @@ method posthog.exception_utils.AnnotatedValue.substituted_because_contains_sensi method posthog.exception_utils.VariableSizeLimiter.add(size) method posthog.exception_utils.VariableSizeLimiter.can_add(size) method posthog.exception_utils.VariableSizeLimiter.get_remaining_space() -method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_flag(key: str) -> Optional[FlagValue] +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_boolean_details(key: str, default_value: bool, *, send_feature_flag_events: bool = True) -> FlagEvaluationDetails[bool] +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_boolean_value(key: str, default_value: bool, *, send_feature_flag_events: bool = True) -> bool +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_flag(key: str, *, send_feature_flag_events: bool = True) -> Optional[FlagValue] method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_flag_payload(key: str) -> Optional[Any] -method posthog.feature_flag_evaluations.FeatureFlagEvaluations.is_enabled(key: str, default_value: bool = False) -> bool +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_float_details(key: str, default_value: float, *, send_feature_flag_events: bool = True) -> FlagEvaluationDetails[float] +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_float_value(key: str, default_value: float, *, send_feature_flag_events: bool = True) -> float +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_integer_details(key: str, default_value: int, *, send_feature_flag_events: bool = True) -> FlagEvaluationDetails[int] +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_integer_value(key: str, default_value: int, *, send_feature_flag_events: bool = True) -> int +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_number_details(key: str, default_value: float, *, send_feature_flag_events: bool = True) -> FlagEvaluationDetails[float] +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_number_value(key: str, default_value: float, *, send_feature_flag_events: bool = True) -> float +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_object_details(key: str, default_value: Dict[str, Any], *, send_feature_flag_events: bool = True) -> FlagEvaluationDetails[Dict[str, Any]] +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_object_value(key: str, default_value: Dict[str, Any], *, send_feature_flag_events: bool = True) -> Dict[str, Any] +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_string_details(key: str, default_value: str, *, send_feature_flag_events: bool = True) -> FlagEvaluationDetails[str] +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_string_value(key: str, default_value: str, *, send_feature_flag_events: bool = True) -> str +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.is_enabled(key: str, default_value: bool = False, *, send_feature_flag_events: bool = True) -> bool method posthog.feature_flag_evaluations.FeatureFlagEvaluations.only(keys: List[str]) -> FeatureFlagEvaluations method posthog.feature_flag_evaluations.FeatureFlagEvaluations.only_accessed() -> FeatureFlagEvaluations method posthog.flag_definition_cache.FlagDefinitionCacheProvider.get_flag_definitions() -> Union[Optional[FlagDefinitionCacheData], Awaitable[Optional[FlagDefinitionCacheData]]]