From 3f8c315251229e59e4f6c0527666da1d2c9acad1 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Thu, 29 Oct 2026 13:52:03 +0100 Subject: [PATCH 01/13] feat(flags): add typed flag value accessors Add get_boolean_value, get_string_value, get_number_value, get_integer_value, get_float_value and get_object_value to the evaluate_flags() snapshot, each with a _details form that returns a FlagEvaluationDetails with an OpenFeature reason, error_code and flag_metadata. The accessors never coerce: they return the caller default when the flag is missing, failed, has no value, or has a value of another type. --- .sampo/changesets/flags-typed-accessors.md | 5 + posthog/__init__.py | 3 + posthog/client.py | 1 + posthog/feature_flag_evaluations.py | 180 +++++++- posthog/test/test_flags_v4_response.py | 492 ++++++++++++++++++++- posthog/types.py | 344 +++++++++++++- references/public_api_snapshot.txt | 42 +- 7 files changed, 1038 insertions(+), 29 deletions(-) create mode 100644 .sampo/changesets/flags-typed-accessors.md diff --git a/.sampo/changesets/flags-typed-accessors.md b/.sampo/changesets/flags-typed-accessors.md new file mode 100644 index 00000000..e6ac1cc1 --- /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: they return your default when the flag is missing, failed, has no value, or has a value of another type. 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/posthog/__init__.py b/posthog/__init__.py index a4c3f367..767862a2 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/client.py b/posthog/client.py index 122f7b68..8b879b2c 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -4782,6 +4782,7 @@ 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; diff --git a/posthog/feature_flag_evaluations.py b/posthog/feature_flag_evaluations.py index ef4e401d..1864e553 100644 --- a/posthog/feature_flag_evaluations.py +++ b/posthog/feature_flag_evaluations.py @@ -5,11 +5,18 @@ 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, + FlagValue, + UnresolvedFlagReason, + _resolve_flag_details, + _resolve_flag_value, +) @dataclass(frozen=True) @@ -30,6 +37,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 @@ -131,6 +140,132 @@ def get_flag(self, key: str) -> Optional[FlagValue]: return False return flag.variant if flag.variant is not None else True + def get_boolean_value(self, key: str, default_value: bool) -> bool: + """Return the flag's boolean value, or ``default_value``. + + Like :meth:`get_flag`, fires ``$feature_flag_called`` on first access. See + :meth:`get_boolean_details` for when the default is returned. + """ + return self.get_boolean_details(key, default_value).value + + def get_boolean_details( + self, key: str, default_value: bool + ) -> 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`. + """ + return self._get_typed_details(key, default_value, "boolean") + + def get_string_value(self, key: str, default_value: str) -> str: + """Return the flag's string value, or ``default_value``. + + Like :meth:`get_flag`, fires ``$feature_flag_called`` on first access. See + :meth:`get_string_details` for when the default is returned. + """ + return self.get_string_details(key, default_value).value + + def get_string_details( + self, key: str, default_value: str + ) -> 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`. + """ + return self._get_typed_details(key, default_value, "string") + + def get_number_value(self, key: str, default_value: float) -> float: + """Return the flag's number value, or ``default_value``. + + An integer value is returned as an ``int``. Like :meth:`get_flag`, fires + ``$feature_flag_called`` on first access. See :meth:`get_number_details` for + when the default is returned. + """ + return self.get_number_details(key, default_value).value + + def get_number_details( + self, key: str, default_value: float + ) -> 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. + ``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`. + """ + return self._get_typed_details(key, default_value, "number") + + def get_integer_value(self, key: str, default_value: int) -> int: + """Return the flag's number value as an ``int``, or ``default_value``. + + Like :meth:`get_flag`, fires ``$feature_flag_called`` on first access. See + :meth:`get_integer_details` for when the default is returned. + """ + return self.get_integer_details(key, default_value).value + + def get_integer_details( + self, key: str, default_value: int + ) -> 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``, is a ``TYPE_MISMATCH``. An integral number such as ``2.0`` is + returned as ``2``. + """ + return self._get_typed_details(key, default_value, "integer") + + def get_float_value(self, key: str, default_value: float) -> float: + """Return the flag's number value as a ``float``, or ``default_value``. + + Like :meth:`get_flag`, fires ``$feature_flag_called`` on first access. See + :meth:`get_float_details` for when the default is returned. + """ + return self.get_float_details(key, default_value).value + + def get_float_details( + self, key: str, default_value: float + ) -> FlagEvaluationDetails[float]: + """Return the flag's number value as a ``float`` with its evaluation details. + + Like :meth:`get_number_details`, but an integer value is returned as a + ``float``. + """ + return self._get_typed_details(key, default_value, "float") + + def get_object_value( + self, key: str, default_value: Dict[str, Any] + ) -> Dict[str, Any]: + """Return the flag's object value, or ``default_value``. + + Like :meth:`get_flag`, fires ``$feature_flag_called`` on first access. See + :meth:`get_object_details` for when the default is returned. + """ + return self.get_object_details(key, default_value).value + + def get_object_details( + self, key: str, default_value: Dict[str, Any] + ) -> 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, 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`. + """ + return self._get_typed_details(key, default_value, "object") + def get_flag_payload(self, key: str) -> Optional[Any]: """Return the payload associated with a flag. @@ -140,8 +275,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,6 +374,43 @@ def _clone_with( accessed=set(self._accessed), ) + def _get_typed_details( + self, key: str, default_value: Any, value_type: str + ) -> FlagEvaluationDetails[Any]: + flag = self._flags.get(key) + self._record_access(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, + "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) -> None: self._accessed.add(key) diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py index 521e66c7..d6f406b0 100644 --- a/posthog/test/test_flags_v4_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -11,7 +11,7 @@ import hashlib import json from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional from unittest import mock import pytest @@ -23,7 +23,11 @@ from posthog.types import ( FeatureFlag, FeatureFlagResult, + FlagEvaluationDetails, + FlagEvaluationErrorCode, + FlagEvaluationReason, FlagMetadata, + UnresolvedFlagReason, normalize_flags_response, to_flags_and_payloads, ) @@ -78,6 +82,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" @@ -87,6 +117,31 @@ def test_vendored_fixtures_match_the_recorded_release_digests(): assert digest == entry["sha256"], name +@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"] @@ -99,6 +154,7 @@ 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.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 @@ -141,6 +197,239 @@ 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) + + +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 _old_server_response(**records: Dict[str, Any]) -> Dict[str, Any]: return { "flags": { @@ -151,6 +440,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": @@ -186,16 +498,150 @@ 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() -> Client: + client = Client(FAKE_TEST_API_KEY, secret_key="test", send=False) + groups = [{"properties": [], "rollout_percentage": 100}] + client.feature_flags = [ + { + "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}]}, + }, + ] + 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 "") + + +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 @@ -259,6 +705,7 @@ 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.error_code is FlagEvaluationErrorCode.PARSE_ERROR assert malformed.failed is True assert malformed.value is None assert (malformed.enabled, malformed.variant, malformed.get_value()) == ( @@ -266,8 +713,9 @@ def test_a_known_field_with_the_wrong_type_fails_only_that_record(path, value): None, False, ) + assert path.split(".")[0] in (malformed.error_message or "") sibling = flags["sibling"] - assert sibling.failed is False + assert sibling.error_code is None assert sibling.value == "compact" @@ -276,8 +724,7 @@ 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["broken"].error_code is FlagEvaluationErrorCode.PARSE_ERROR assert flags["sibling"].value == "compact" @@ -291,7 +738,7 @@ def test_unknown_fields_and_null_optional_fields_are_ignored(): "garden-layout" ] - assert flag.failed is False + assert flag.error_code is None assert flag.value == "compact" assert isinstance(flag.metadata, FlagMetadata) assert flag.metadata.variant_key is None @@ -316,7 +763,7 @@ def test_experiment_and_holdout_ids_in_the_metadata_are_ignored(field, value): "garden-layout" ] - assert flag.failed is False + assert flag.error_code is None assert flag.value == "compact" assert isinstance(flag.metadata, FlagMetadata) assert not hasattr(flag.metadata, field) @@ -353,7 +800,7 @@ def test_a_record_without_value_is_read_as_an_older_server_record(): 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, @@ -371,9 +818,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 test_feature_flag_value_defaults_to_the_legacy_value(): @@ -712,20 +1158,30 @@ def test_feature_flag_called_properties_are_unchanged_for_a_v1_flag(client): ) +def test_typed_accessors_report_access_like_get_flag(client): + with mock.patch.object(client, "capture") as capture: + snapshot = _evaluate(client, _typed_response({"string-flag": "compact"})) + snapshot.get_number_details("string-flag", 0) + 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 + assert snapshot.only_accessed().keys == ["string-flag"] + + 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 6084c925..82cd07d2 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -1,12 +1,26 @@ +import copy import json import logging import math -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import Enum -from typing import Any, Callable, List, Optional, TypedDict, Union, cast +from typing import ( + Any, + Callable, + Dict, + Generic, + List, + Optional, + TypedDict, + TypeVar, + Union, + cast, +) FlagValue = Union[bool, str] +_T = TypeVar("_T") + def _reject_json_constant(value: str) -> None: raise ValueError("Invalid JSON constant") @@ -213,6 +227,51 @@ def from_json(cls, resp: Any) -> Union["FlagMetadata", LegacyFlagMetadata]: ) +class FlagEvaluationErrorCode(str, Enum): + """Why a typed flag accessor returned the caller default instead of a value. + + The values are the OpenFeature error codes. + """ + + 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``.""" @@ -222,6 +281,13 @@ 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: @@ -242,6 +308,8 @@ class FeatureFlag: ``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 @@ -251,6 +319,8 @@ class FeatureFlag: metadata: Union[FlagMetadata, LegacyFlagMetadata] value: Any = _DERIVED_VALUE failed: bool = False + error_code: Optional[FlagEvaluationErrorCode] = None + error_message: Optional[str] = None def __post_init__(self) -> None: if self.value is _DERIVED_VALUE: @@ -284,13 +354,17 @@ def from_json(cls, resp: Any) -> "FeatureFlag": else: metadata = LegacyFlagMetadata(payload=None) + failed = resp.get("failed") is True + error_code, error_message = _record_error(failed, reason, 1) return cls( key=resp.get("key"), enabled=resp.get("enabled"), variant=resp.get("variant"), reason=reason, metadata=metadata, - failed=resp.get("failed") is True, + failed=failed, + error_code=error_code, + error_message=error_message, ) @classmethod @@ -338,6 +412,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)``. @@ -355,7 +444,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: @@ -418,7 +507,7 @@ def _parse_typed_record(key: str, resp: Any) -> FeatureFlag: # Only config version 1 records carry an experiment ID. It is checked, # like every known field, but not kept. _read_int(raw_metadata.get("experiment_id"), "metadata.experiment_id") - except _MalformedRecord: + except _MalformedRecord as error: return FeatureFlag( key=key, enabled=False, @@ -427,8 +516,14 @@ def _parse_typed_record(key: str, resp: Any) -> FeatureFlag: metadata=LegacyFlagMetadata(payload=None), value=None, failed=True, + error_code=FlagEvaluationErrorCode.PARSE_ERROR, + error_message=f"The flag record's {error} field has the wrong JSON type.", ) + 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, @@ -437,6 +532,241 @@ def _parse_typed_record(key: str, resp: Any) -> FeatureFlag: 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) + + +def _read_number(value: Any) -> Any: + if isinstance(value, (int, float)) and not isinstance(value, bool): + return value + return _NOT_ACCEPTED + + +def _read_integer(value: Any) -> Any: + if isinstance(value, int) and not isinstance(value, bool): + return value + if isinstance(value, float) and value.is_integer(): + return int(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: + return _NOT_ACCEPTED + return _NOT_ACCEPTED + + +_NOT_ACCEPTED: Any = object() + +# Reads a flag value as one accessor type, or returns ``_NOT_ACCEPTED``. 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": lambda value: ( + copy.deepcopy(value) if isinstance(value, dict) else _NOT_ACCEPTED + ), +} + +# 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": + return f"Flag '{key}' has the number {value!r}, which is not an 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 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, ) @@ -458,7 +788,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 a548f883..f31572aa 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,7 @@ 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.FlagValue -> posthog.types.FlagValue alias posthog.feature_flag_evaluations.UnresolvedFlagReason -> posthog.types.UnresolvedFlagReason alias posthog.feature_flags.FlagValue -> posthog.types.FlagValue @@ -1129,6 +1133,8 @@ 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] @@ -1150,6 +1156,24 @@ 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 @@ -1160,6 +1184,7 @@ 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: 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 @@ -1343,10 +1368,13 @@ 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], value: Any = _DERIVED_VALUE, failed: bool = False) +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.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: int, payload: Optional[str], version: 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 @@ -1812,8 +1840,20 @@ 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_boolean_details(key: str, default_value: bool) -> FlagEvaluationDetails[bool] +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_boolean_value(key: str, default_value: bool) -> bool method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_flag(key: str) -> Optional[FlagValue] method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_flag_payload(key: str) -> Optional[Any] +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_float_details(key: str, default_value: float) -> FlagEvaluationDetails[float] +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_float_value(key: str, default_value: float) -> float +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_integer_details(key: str, default_value: int) -> FlagEvaluationDetails[int] +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_integer_value(key: str, default_value: int) -> int +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_number_details(key: str, default_value: float) -> FlagEvaluationDetails[float] +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_number_value(key: str, default_value: float) -> float +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_object_details(key: str, default_value: Dict[str, Any]) -> FlagEvaluationDetails[Dict[str, Any]] +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_object_value(key: str, default_value: Dict[str, Any]) -> Dict[str, Any] +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_string_details(key: str, default_value: str) -> FlagEvaluationDetails[str] +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_string_value(key: str, default_value: str) -> str method posthog.feature_flag_evaluations.FeatureFlagEvaluations.is_enabled(key: str, default_value: bool = False) -> bool method posthog.feature_flag_evaluations.FeatureFlagEvaluations.only(keys: List[str]) -> FeatureFlagEvaluations method posthog.feature_flag_evaluations.FeatureFlagEvaluations.only_accessed() -> FeatureFlagEvaluations From 33b9f61decb0a559ced673035ead0cca89d5ffbd Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Thu, 29 Oct 2026 15:45:09 +0100 Subject: [PATCH 02/13] fix(flags): limit typed integers to the exact double range The integer accessors reject numbers beyond 2**53 - 1 in magnitude, such as 1e21, so every SDK returns the same result for the same flag. A test also pins that a throwing before_send does not break a typed read. --- posthog/feature_flag_evaluations.py | 5 +-- posthog/test/test_flags_v4_response.py | 42 ++++++++++++++++++++++++++ posthog/types.py | 22 +++++++++++--- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/posthog/feature_flag_evaluations.py b/posthog/feature_flag_evaluations.py index 1864e553..db6ad221 100644 --- a/posthog/feature_flag_evaluations.py +++ b/posthog/feature_flag_evaluations.py @@ -219,8 +219,9 @@ def get_integer_details( """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``, is a ``TYPE_MISMATCH``. An integral number such as ``2.0`` is - returned as ``2``. + 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") diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py index d6f406b0..cab52ab9 100644 --- a/posthog/test/test_flags_v4_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -421,6 +421,34 @@ def test_the_integer_accessor_rejects_a_fractional_number(client): 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 + 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. + assert snapshot.get_number_value("n", 7) == value + else: + assert (details.value, type(details.value)) == (expected, int) + assert details.error_code is None + + def test_a_returned_object_cannot_change_the_snapshot(client): snapshot = _evaluate(client, _typed_response(TYPED_VALUES)) @@ -1172,6 +1200,20 @@ def test_typed_accessors_report_access_like_get_flag(client): assert snapshot.only_accessed().keys == ["string-flag"] +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_reads_the_v4_response(): with mock.patch( "posthog.async_client._async_flags", diff --git a/posthog/types.py b/posthog/types.py index 82cd07d2..f95c988d 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -579,11 +579,20 @@ def _read_number(value: Any) -> Any: return _NOT_ACCEPTED +# Every SDK reads numbers as IEEE-754 doubles, which hold every integer up to this +# magnitude exactly. Larger integers are not accepted, so all SDKs agree. +_MAX_SAFE_INTEGER = 2**53 - 1 + + def _read_integer(value: Any) -> Any: - if isinstance(value, int) and not isinstance(value, bool): - return value if isinstance(value, float) and value.is_integer(): - return int(value) + value = int(value) + if ( + isinstance(value, int) + and not isinstance(value, bool) + and abs(value) <= _MAX_SAFE_INTEGER + ): + return value return _NOT_ACCEPTED @@ -659,7 +668,12 @@ def _type_mismatch_message( ) -> str: actual = _json_type_name(value) if actual == "number" and value_type == "integer": - return f"Flag '{key}' has the number {value!r}, which is not an 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." From bc749e79804ab62a964c38d126261c5ad4d14012 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Thu, 29 Oct 2026 16:06:37 +0100 Subject: [PATCH 03/13] feat: add a per-read send_feature_flag_events option to snapshot reads --- .../changesets/snapshot-flag-event-option.md | 5 + posthog/feature_flag_evaluations.py | 29 ++++- posthog/test/test_evaluate_flags.py | 120 ++++++++++++++++++ references/public_api_snapshot.txt | 4 +- 4 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 .sampo/changesets/snapshot-flag-event-option.md 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/feature_flag_evaluations.py b/posthog/feature_flag_evaluations.py index 13d8c7df..ef4859b4 100644 --- a/posthog/feature_flag_evaluations.py +++ b/posthog/feature_flag_evaluations.py @@ -95,7 +95,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. @@ -105,23 +111,31 @@ 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: @@ -236,9 +250,14 @@ def _clone_with( accessed=set(self._accessed), ) - def _record_access(self, key: str) -> 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/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 63ce6a50..fda69c51 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -1804,9 +1804,9 @@ 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_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.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]]] From 94b5afdac33c3117bbf1d823f41f0e11d2f4aa2b Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Thu, 29 Oct 2026 20:53:12 +0100 Subject: [PATCH 04/13] test(flags): check typed details of unreadable, failed and sparse records --- posthog/test/test_flags_v4_response.py | 57 ++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py index a25d90ef..c2e16ae4 100644 --- a/posthog/test/test_flags_v4_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -1103,6 +1103,63 @@ 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 From d6c75932ab6993f53389a5331a17a17f8fb70fc5 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Thu, 29 Oct 2026 20:54:11 +0100 Subject: [PATCH 05/13] fix(flags): read an object too deeply nested to copy as unreadable The object accessors copy the value so a caller cannot change the snapshot. A value nested deeply enough made that copy raise RecursionError; it now returns the caller default with PARSE_ERROR, so a typed read never throws. --- posthog/feature_flag_evaluations.py | 8 +++---- posthog/test/test_flags_v4_response.py | 22 +++++++++++++++++++ posthog/types.py | 29 +++++++++++++++++++++----- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/posthog/feature_flag_evaluations.py b/posthog/feature_flag_evaluations.py index db6ad221..13669e05 100644 --- a/posthog/feature_flag_evaluations.py +++ b/posthog/feature_flag_evaluations.py @@ -260,10 +260,10 @@ def get_object_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, 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`. + 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`. """ return self._get_typed_details(key, default_value, "object") diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py index c2e16ae4..a10a4829 100644 --- a/posthog/test/test_flags_v4_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -459,6 +459,28 @@ def test_a_returned_object_cannot_change_the_snapshot(client): 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": { diff --git a/posthog/types.py b/posthog/types.py index 14e267a8..e37ca3da 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -584,18 +584,31 @@ def _read_float(value: Any) -> Any: _NOT_ACCEPTED: Any = object() +# Returned by a reader for a value of the right type that it cannot read. +_UNREADABLE: Any = object() -# Reads a flag value as one accessor type, or returns ``_NOT_ACCEPTED``. A returned -# object is a copy, so a caller cannot change the evaluated flag. + +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": lambda value: ( - copy.deepcopy(value) if isinstance(value, dict) else _NOT_ACCEPTED - ), + "object": _read_object, } # The contract's OpenFeature reasons for config version 2 reason codes that carry @@ -704,6 +717,12 @@ def resolved( 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: From 8333a516764277ccaef88b49ff66f0ee178a42b0 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Thu, 29 Oct 2026 21:50:24 +0100 Subject: [PATCH 06/13] docs(flags): name the error fields and unreadable reads in the changeset --- .sampo/changesets/flags-typed-accessors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.sampo/changesets/flags-typed-accessors.md b/.sampo/changesets/flags-typed-accessors.md index e6ac1cc1..87cf6299 100644 --- a/.sampo/changesets/flags-typed-accessors.md +++ b/.sampo/changesets/flags-typed-accessors.md @@ -2,4 +2,4 @@ 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: they return your default when the flag is missing, failed, has no value, or has a value of another type. Number and object values need a server that sends typed values; the SDK logs once per client when it reads a response without them. +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. `FeatureFlag` gains `error_code` and `error_message`, which say why a flag failed or could not be read. Number and object values need a server that sends typed values; the SDK logs once per client when it reads a response without them. From e0ce99f4329f328dd4b5718a63b5ebb3ec9bf818 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Thu, 29 Oct 2026 21:52:38 +0100 Subject: [PATCH 07/13] fix(flags): say a too-deep value is nested too deeply, not mistyped --- posthog/test/test_flags_v4_response.py | 4 ++++ posthog/types.py | 12 ++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py index 1cec4ef9..fcc5c694 100644 --- a/posthog/test/test_flags_v4_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -948,6 +948,10 @@ def test_a_value_too_deeply_nested_to_encode_fails_only_that_record(): 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" diff --git a/posthog/types.py b/posthog/types.py index ac1b32ce..13e790cc 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -497,7 +497,9 @@ 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." + ) config_version = ( metadata.config_version if isinstance(metadata, FlagMetadata) else None @@ -833,7 +835,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, @@ -844,8 +848,8 @@ def _malformed_record(key: str, malformed_field: str) -> FeatureFlag: value=None, failed=True, error_code=FlagEvaluationErrorCode.PARSE_ERROR, - error_message=f"The flag record's {malformed_field} field has the wrong" - " JSON type.", + error_message=message + or f"The flag record's {malformed_field} field has the wrong JSON type.", ) object.__setattr__(flag, "_malformed_field", malformed_field) return flag From 4425340d9af113bd3784492ff3fc6fad81d35f4e Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Fri, 30 Oct 2026 14:03:53 +0100 Subject: [PATCH 08/13] feat(flags): add the per-read send_feature_flag_events option to typed accessors Every typed value and details accessor on the evaluate_flags() snapshot now takes the keyword-only send_feature_flag_events option that is_enabled() and get_flag() take. With send_feature_flag_events=False the read sends no $feature_flag_called and leaves the dedupe cache alone, so a later read that sends events still reports the flag. The key still counts as accessed for only_accessed(). The value form passes the option to its details form, so both forms behave the same. --- .sampo/changesets/flags-typed-accessors.md | 2 +- posthog/feature_flag_evaluations.py | 144 +++++++++++++++------ posthog/test/test_flags_v4_response.py | 97 +++++++++++++- references/public_api_snapshot.txt | 24 ++-- 4 files changed, 215 insertions(+), 52 deletions(-) diff --git a/.sampo/changesets/flags-typed-accessors.md b/.sampo/changesets/flags-typed-accessors.md index 534ffc51..d956f836 100644 --- a/.sampo/changesets/flags-typed-accessors.md +++ b/.sampo/changesets/flags-typed-accessors.md @@ -2,4 +2,4 @@ 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. `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. +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. 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. `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/posthog/feature_flag_evaluations.py b/posthog/feature_flag_evaluations.py index e6200655..dff95869 100644 --- a/posthog/feature_flag_evaluations.py +++ b/posthog/feature_flag_evaluations.py @@ -154,16 +154,21 @@ def get_flag( return False return flag.variant if flag.variant is not None else True - def get_boolean_value(self, key: str, default_value: bool) -> bool: + 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. See - :meth:`get_boolean_details` for when the default is returned. + 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).value + 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 + self, key: str, default_value: bool, *, send_feature_flag_events: bool = True ) -> FlagEvaluationDetails[bool]: """Return the flag's boolean value with its evaluation details. @@ -173,19 +178,30 @@ def get_boolean_details( 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`. + + 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") + return self._get_typed_details( + key, default_value, "boolean", send_feature_flag_events + ) - def get_string_value(self, key: str, default_value: str) -> str: + 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. See - :meth:`get_string_details` for when the default is returned. + 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).value + 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 + self, key: str, default_value: str, *, send_feature_flag_events: bool = True ) -> FlagEvaluationDetails[str]: """Return the flag's string value with its evaluation details. @@ -194,20 +210,31 @@ def get_string_details( 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`. + + 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") + return self._get_typed_details( + key, default_value, "string", send_feature_flag_events + ) - def get_number_value(self, key: str, default_value: float) -> float: + 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``. Like :meth:`get_flag`, fires - ``$feature_flag_called`` on first access. See :meth:`get_number_details` for - when the default is returned. + ``$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).value + 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 + self, key: str, default_value: float, *, send_feature_flag_events: bool = True ) -> FlagEvaluationDetails[float]: """Return the flag's number value with its evaluation details. @@ -216,19 +243,30 @@ def get_number_details( 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`. + + 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") + return self._get_typed_details( + key, default_value, "number", send_feature_flag_events + ) - def get_integer_value(self, key: str, default_value: int) -> int: + 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. See - :meth:`get_integer_details` for when the default is returned. + 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).value + 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 + 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. @@ -237,38 +275,58 @@ def get_integer_details( 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") + return self._get_typed_details( + key, default_value, "integer", send_feature_flag_events + ) - def get_float_value(self, key: str, default_value: float) -> float: + 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. See - :meth:`get_float_details` for when the default is returned. + 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).value + 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 + 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 an integer value is returned as a ``float``. """ - return self._get_typed_details(key, default_value, "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] + 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. See - :meth:`get_object_details` for when the default is returned. + 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).value + 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] + 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. @@ -278,8 +336,14 @@ def get_object_details( 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`. + + 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") + 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. @@ -390,10 +454,14 @@ def _clone_with( ) def _get_typed_details( - self, key: str, default_value: Any, value_type: str + self, + key: str, + default_value: Any, + value_type: str, + send_feature_flag_events: bool, ) -> FlagEvaluationDetails[Any]: flag = self._flags.get(key) - self._record_access(key, True) + self._record_access(key, send_feature_flag_events) if flag is None and key in self._unresolved_flags: # The flag exists, so say why it has no value. return replace( diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py index fcc5c694..57736739 100644 --- a/posthog/test/test_flags_v4_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -7,11 +7,12 @@ from __future__ import annotations +import contextlib import copy import hashlib import json from pathlib import Path -from typing import Any, Callable, Dict, List, Optional +from typing import Any, AsyncIterator, Callable, Dict, List, Optional, Tuple from unittest import mock import pytest @@ -1557,6 +1558,100 @@ def test_typed_accessors_report_access_like_get_flag(client): assert snapshot.only_accessed().keys == ["string-flag"] +# 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] + + def test_a_throwing_before_send_does_not_break_a_typed_read(): calls = [] diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 2b212c12..5bf5ded2 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -1840,20 +1840,20 @@ 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_boolean_details(key: str, default_value: bool) -> FlagEvaluationDetails[bool] -method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_boolean_value(key: str, default_value: bool) -> bool +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.get_float_details(key: str, default_value: float) -> FlagEvaluationDetails[float] -method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_float_value(key: str, default_value: float) -> float -method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_integer_details(key: str, default_value: int) -> FlagEvaluationDetails[int] -method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_integer_value(key: str, default_value: int) -> int -method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_number_details(key: str, default_value: float) -> FlagEvaluationDetails[float] -method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_number_value(key: str, default_value: float) -> float -method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_object_details(key: str, default_value: Dict[str, Any]) -> FlagEvaluationDetails[Dict[str, Any]] -method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_object_value(key: str, default_value: Dict[str, Any]) -> Dict[str, Any] -method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_string_details(key: str, default_value: str) -> FlagEvaluationDetails[str] -method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_string_value(key: str, default_value: str) -> str +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 From 0a333e6584dd8d86d0ad1a3e0000f103574acda5 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Fri, 30 Oct 2026 14:04:48 +0100 Subject: [PATCH 09/13] fix(flags): include the definition version in local typed details Typed details of a locally evaluated flag left the flag version out of flag_metadata. The local flag definition carries the version, but the evaluate_flags() snapshot record set it to None. The record now takes the version from the definition, and the details add it to flag_metadata when it is present. The record change is the same one PostHog/posthog-python#1149 makes, so $feature_flag_called for a snapshot read of a locally evaluated flag also carries $feature_flag_version. --- .sampo/changesets/flags-typed-accessors.md | 2 +- posthog/client.py | 4 +- posthog/feature_flag_evaluations.py | 1 + posthog/test/test_flags_v4_response.py | 43 +++++++++++++++++++++- 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/.sampo/changesets/flags-typed-accessors.md b/.sampo/changesets/flags-typed-accessors.md index d956f836..4ab8015e 100644 --- a/.sampo/changesets/flags-typed-accessors.md +++ b/.sampo/changesets/flags-typed-accessors.md @@ -2,4 +2,4 @@ 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. 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. `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. +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. 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/posthog/client.py b/posthog/client.py index eb5b7af5..a6836e8b 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -4931,9 +4931,7 @@ def evaluate_flags( 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")), diff --git a/posthog/feature_flag_evaluations.py b/posthog/feature_flag_evaluations.py index dff95869..8e1784f3 100644 --- a/posthog/feature_flag_evaluations.py +++ b/posthog/feature_flag_evaluations.py @@ -479,6 +479,7 @@ def _get_typed_details( 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, diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py index 57736739..a5fb25c9 100644 --- a/posthog/test/test_flags_v4_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -555,10 +555,11 @@ async def test_the_async_client_logs_records_without_typed_values_once(caplog): assert len(logged) == 1 -def _local_client() -> Client: +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}] - client.feature_flags = [ + definitions: List[Dict[str, Any]] = [ { "id": 11, "key": "legacy-layout", @@ -579,6 +580,10 @@ def _local_client() -> Client: "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 @@ -618,6 +623,40 @@ def test_locally_evaluated_flags_have_a_derived_reason_and_config_version_1( 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})) From 164cd90413402ade93bb1eeae633d5873f92d7e6 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Fri, 30 Oct 2026 19:19:10 +0100 Subject: [PATCH 10/13] fix(flags): read integers beyond 2**53 - 1 as doubles in the number accessor Numbers are IEEE-754 doubles in every PostHog SDK. get_number_value and get_number_details returned an integer beyond 2**53 - 1 in magnitude exactly, so Python could return a value JavaScript cannot. They now return the nearest float. Integers within range keep their int type, the integer accessors keep TYPE_MISMATCH beyond the range, and the float accessors are unchanged. An integer too large for any double is a TYPE_MISMATCH, as in the float accessor. --- .sampo/changesets/flags-typed-accessors.md | 2 +- posthog/feature_flag_evaluations.py | 7 +++- posthog/test/test_flags_v4_response.py | 44 +++++++++++++++++++++- posthog/types.py | 22 +++++++---- 4 files changed, 63 insertions(+), 12 deletions(-) diff --git a/.sampo/changesets/flags-typed-accessors.md b/.sampo/changesets/flags-typed-accessors.md index 4ab8015e..1920b0b9 100644 --- a/.sampo/changesets/flags-typed-accessors.md +++ b/.sampo/changesets/flags-typed-accessors.md @@ -2,4 +2,4 @@ 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. 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. +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. Numbers are IEEE-754 doubles: `get_number_value` returns an integer beyond `2**53 - 1` in magnitude as the nearest `float`, and `get_integer_value` returns your default with `TYPE_MISMATCH` for it. 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/posthog/feature_flag_evaluations.py b/posthog/feature_flag_evaluations.py index 8e1784f3..0360757e 100644 --- a/posthog/feature_flag_evaluations.py +++ b/posthog/feature_flag_evaluations.py @@ -224,7 +224,8 @@ def get_number_value( ) -> float: """Return the flag's number value, or ``default_value``. - An integer value is returned as an ``int``. Like :meth:`get_flag`, fires + 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. @@ -239,7 +240,9 @@ def get_number_details( """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. - ``default_value`` is returned when the flag is not in the evaluation, + 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. ``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`. diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py index a5fb25c9..05e40e55 100644 --- a/posthog/test/test_flags_v4_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -431,7 +431,10 @@ def test_the_integer_accessor_rejects_a_fractional_number(client): (-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), ], ) @@ -444,13 +447,50 @@ def test_the_integer_accessor_accepts_only_exact_integers(client, value, expecte 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. - assert snapshot.get_number_value("n", 7) == value + # 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) + + +def test_the_number_accessor_rejects_an_integer_too_large_for_a_double(client): + snapshot = _evaluate(client, _typed_response({"n": 10**400})) + + details = snapshot.get_number_details("n", 7) + + assert details.value == 7 + assert details.error_code is FlagEvaluationErrorCode.TYPE_MISMATCH + assert details.error_message == ( + f"Flag 'n' has the number {10**400}, which is outside the range of a double." + ) + + def test_a_returned_object_cannot_change_the_snapshot(client): snapshot = _evaluate(client, _typed_response(TYPED_VALUES)) diff --git a/posthog/types.py b/posthog/types.py index 13e790cc..1f762f2d 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -554,17 +554,20 @@ class FlagEvaluationDetails(Generic[_T]): flag_metadata: Dict[str, FlagMetadataValue] = field(default_factory=dict) -def _read_number(value: Any) -> Any: - if isinstance(value, (int, float)) and not isinstance(value, bool): - return value - return _NOT_ACCEPTED - - # Every SDK reads numbers as IEEE-754 doubles, which hold every integer up to this -# magnitude exactly. Larger integers are not accepted, so all SDKs agree. +# 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) @@ -661,6 +664,11 @@ 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 == "number": + return ( + f"Flag '{key}' has the number {value!r}, which is outside the range of" + " a double." + ) 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." From 97ce8d684e61d03633e5d50fa22ad14ca38b0414 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Fri, 30 Oct 2026 19:22:09 +0100 Subject: [PATCH 11/13] fix(flags): send no flag event for a typed read of the wrong type A typed accessor that resolves to TYPE_MISMATCH returned the caller's default and still sent $feature_flag_called, which took the dedupe slot, so the later read of the right type sent nothing. A read of the wrong type is a bug in the calling code, not a use of the flag. It now sends no event and takes no dedupe slot, so the next read of the right type, or a get_flag() or is_enabled() read, reports the flag. The client logs the error_message once per flag key and requested type, in a cache bounded like the flag-event dedupe. The read still counts as accessed for only_accessed(). A false or null value read as another type, missing, failed and unreadable flags, and reads with send_feature_flag_events=False are unchanged. --- .sampo/changesets/flags-typed-accessors.md | 2 +- posthog/async_client.py | 12 ++ posthog/client.py | 12 ++ posthog/feature_flag_evaluations.py | 33 ++++- posthog/test/test_flags_v4_response.py | 137 ++++++++++++++++++++- references/public_api_snapshot.txt | 1 + 6 files changed, 189 insertions(+), 8 deletions(-) diff --git a/.sampo/changesets/flags-typed-accessors.md b/.sampo/changesets/flags-typed-accessors.md index 1920b0b9..7b5b53f2 100644 --- a/.sampo/changesets/flags-typed-accessors.md +++ b/.sampo/changesets/flags-typed-accessors.md @@ -2,4 +2,4 @@ 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. Numbers are IEEE-754 doubles: `get_number_value` returns an integer beyond `2**53 - 1` in magnitude as the nearest `float`, and `get_integer_value` returns your default with `TYPE_MISMATCH` for it. 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. +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`, and `get_integer_value` returns your default with `TYPE_MISMATCH` for it. 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/posthog/async_client.py b/posthog/async_client.py index 323b16b8..167e2e21 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -186,6 +186,9 @@ def __init__( 0, feature_flags_request_max_retries ) self.distinct_ids_feature_flags_reported = SizeLimitedDict(_MAX_DICT_SIZE, set) + # 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._feature_flag_evaluations_host_cache: Optional[ _FeatureFlagEvaluationsHost ] = None @@ -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 a6836e8b..ff24962c 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -926,6 +926,9 @@ def __init__( ) self.poller: Optional[Poller] = None self.distinct_ids_feature_flags_reported = SizeLimitedDict(MAX_DICT_SIZE, set) + # 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.flag_fallback_cache_url = flag_fallback_cache_url self.flag_cache = self._initialize_flag_cache(flag_fallback_cache_url) self.flag_definition_version = 0 @@ -5028,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 0360757e..d4f82576 100644 --- a/posthog/feature_flag_evaluations.py +++ b/posthog/feature_flag_evaluations.py @@ -12,6 +12,7 @@ from posthog.types import ( FeatureFlag, FlagEvaluationDetails, + FlagEvaluationErrorCode, FlagValue, UnresolvedFlagReason, _resolve_flag_details, @@ -51,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: @@ -177,7 +181,8 @@ def get_boolean_details( 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`. + :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 @@ -209,7 +214,8 @@ def get_string_details( 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`. + :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 @@ -245,7 +251,9 @@ def get_number_details( it. ``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`. + ``$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 @@ -338,7 +346,9 @@ def get_object_details( 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`. + ``$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 @@ -462,9 +472,22 @@ def _get_typed_details( 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) - self._record_access(key, send_feature_flag_events) if flag is None and key in self._unresolved_flags: # The flag exists, so say why it has no value. return replace( diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py index 05e40e55..22ec8de8 100644 --- a/posthog/test/test_flags_v4_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -1623,10 +1623,14 @@ def test_feature_flag_called_properties_are_unchanged_for_a_v1_flag(client): ) -def test_typed_accessors_report_access_like_get_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() @@ -1634,7 +1638,6 @@ def test_typed_accessors_report_access_like_get_flag(client): assert properties["$feature_flag_response"] == "compact" # A type mismatch is not an evaluation error. assert "$feature_flag_error" not in properties - assert snapshot.only_accessed().keys == ["string-flag"] # The flag each typed accessor reads in the per-read event option tests, and the @@ -1731,6 +1734,136 @@ async def test_a_typed_read_sends_one_event_by_default(method, client_kind): 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 = [] diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 5bf5ded2..6bb9743a 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -326,6 +326,7 @@ 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 From d25a101b0aeca5e7328940a5f15fa542737257dc Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Fri, 30 Oct 2026 19:32:05 +0100 Subject: [PATCH 12/13] fix(flags): read an integer beyond the range of a double as an infinity The float accessor returned TYPE_MISMATCH for an integer too large to convert to a float, and the number accessor followed it. JavaScript parses such a number as an infinity, the nearest IEEE-754 double. Both accessors now return that infinity, with its sign, so every PostHog SDK reads the same value. The integer accessors keep TYPE_MISMATCH. --- .sampo/changesets/flags-typed-accessors.md | 2 +- posthog/feature_flag_evaluations.py | 11 ++++++----- posthog/test/test_flags_v4_response.py | 20 +++++++++++--------- posthog/types.py | 8 ++------ 4 files changed, 20 insertions(+), 21 deletions(-) diff --git a/.sampo/changesets/flags-typed-accessors.md b/.sampo/changesets/flags-typed-accessors.md index 7b5b53f2..ea408e64 100644 --- a/.sampo/changesets/flags-typed-accessors.md +++ b/.sampo/changesets/flags-typed-accessors.md @@ -2,4 +2,4 @@ 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`, and `get_integer_value` returns your default with `TYPE_MISMATCH` for it. 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. +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/posthog/feature_flag_evaluations.py b/posthog/feature_flag_evaluations.py index d4f82576..611084f8 100644 --- a/posthog/feature_flag_evaluations.py +++ b/posthog/feature_flag_evaluations.py @@ -248,9 +248,10 @@ def get_number_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. ``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 + 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. @@ -308,8 +309,8 @@ def get_float_details( ) -> FlagEvaluationDetails[float]: """Return the flag's number value as a ``float`` with its evaluation details. - Like :meth:`get_number_details`, but an integer value is returned as a - ``float``. + 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 diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py index 22ec8de8..f019e751 100644 --- a/posthog/test/test_flags_v4_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -11,6 +11,7 @@ import copy import hashlib import json +import math from pathlib import Path from typing import Any, AsyncIterator, Callable, Dict, List, Optional, Tuple from unittest import mock @@ -479,15 +480,16 @@ def test_the_number_accessor_reads_integers_beyond_the_safe_range_as_doubles( assert snapshot.get_float_value("n", 7.0) == float(value) -def test_the_number_accessor_rejects_an_integer_too_large_for_a_double(client): - snapshot = _evaluate(client, _typed_response({"n": 10**400})) - - details = snapshot.get_number_details("n", 7) - - assert details.value == 7 - assert details.error_code is FlagEvaluationErrorCode.TYPE_MISMATCH - assert details.error_message == ( - f"Flag 'n' has the number {10**400}, which is outside the range of a double." +@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 ) diff --git a/posthog/types.py b/posthog/types.py index 1f762f2d..e3845043 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -587,7 +587,8 @@ def _read_float(value: Any) -> Any: try: return float(value) except OverflowError: - return _NOT_ACCEPTED + # The nearest double is an infinity, as JavaScript parses the number. + return math.inf if value > 0 else -math.inf return _NOT_ACCEPTED @@ -664,11 +665,6 @@ 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 == "number": - return ( - f"Flag '{key}' has the number {value!r}, which is outside the range of" - " a double." - ) 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." From b7492995a1a0409f1021b7479632be839520d7e0 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Fri, 30 Oct 2026 19:33:36 +0100 Subject: [PATCH 13/13] refactor(flags): keep the type mismatch log beside the other flag log state Move the client's record of logged type mismatches next to _logged_untyped_flags_response, the other once-per-client flag log state, in both clients. --- posthog/async_client.py | 6 +++--- posthog/client.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/posthog/async_client.py b/posthog/async_client.py index 167e2e21..a218c6d6 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -186,9 +186,6 @@ def __init__( 0, feature_flags_request_max_retries ) self.distinct_ids_feature_flags_reported = SizeLimitedDict(_MAX_DICT_SIZE, set) - # 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._feature_flag_evaluations_host_cache: Optional[ _FeatureFlagEvaluationsHost ] = None @@ -234,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( diff --git a/posthog/client.py b/posthog/client.py index ff24962c..60d081ef 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -926,9 +926,6 @@ def __init__( ) self.poller: Optional[Poller] = None self.distinct_ids_feature_flags_reported = SizeLimitedDict(MAX_DICT_SIZE, set) - # 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.flag_fallback_cache_url = flag_fallback_cache_url self.flag_cache = self._initialize_flag_cache(flag_fallback_cache_url) self.flag_definition_version = 0 @@ -1010,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