From 3f93c4ae4d85b7dfc4e492f2f14a341c6ec3fd81 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Wed, 7 Oct 2026 10:33:58 +0200 Subject: [PATCH 01/35] feat(flags): read the /flags?v=3 response with typed values Request /flags?v=3 from the sync and async clients and keep each flag's typed value, structured reason and metadata on the parsed record. - A record with a value member is a v3 record. A record without one comes from an older server: its value is variant, or enabled without one, and its config version is 1. - A known field with the wrong JSON type fails only that record with PARSE_ERROR; unknown fields are ignored. - Legacy getters, callbacks and $feature_flag_called keep reading the legacy rendering, now derived from value in one place: a string is the variant, null is disabled, and a number or object is enabled with the value as a JSON-encoded payload. - evaluate_flags() snapshots gain get_boolean/string/number/object_value and _details accessors. They never coerce and return the caller default for missing, failed, null and mismatched values. --- .sampo/changesets/flags-v3-response.md | 5 + posthog/__init__.py | 2 + posthog/_async_request.py | 2 +- posthog/async_client.py | 1 + posthog/client.py | 1 + posthog/feature_flag_evaluations.py | 108 +- posthog/request.py | 2 +- .../feature_flag_rules_v2/SOURCE.json | 27 + .../legacy_projection.json | 661 ++++ .../feature_flag_rules_v2/readers.json | 1087 ++++++ .../feature_flag_rules_v2/responses.json | 2235 ++++++++++++ .../v2_value_evaluation.json | 3080 +++++++++++++++++ posthog/test/snapshots/flags_request.json | 2 +- posthog/test/test_async_request.py | 2 +- posthog/test/test_feature_flag.py | 1 + posthog/test/test_flags_v3_response.py | 866 +++++ posthog/types.py | 362 +- references/public_api_snapshot.txt | 41 +- 18 files changed, 8475 insertions(+), 10 deletions(-) create mode 100644 .sampo/changesets/flags-v3-response.md create mode 100644 posthog/test/fixtures/feature_flag_rules_v2/SOURCE.json create mode 100644 posthog/test/fixtures/feature_flag_rules_v2/legacy_projection.json create mode 100644 posthog/test/fixtures/feature_flag_rules_v2/readers.json create mode 100644 posthog/test/fixtures/feature_flag_rules_v2/responses.json create mode 100644 posthog/test/fixtures/feature_flag_rules_v2/v2_value_evaluation.json create mode 100644 posthog/test/test_flags_v3_response.py diff --git a/.sampo/changesets/flags-v3-response.md b/.sampo/changesets/flags-v3-response.md new file mode 100644 index 000000000..9be4b3e4b --- /dev/null +++ b/.sampo/changesets/flags-v3-response.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Request `/flags?v=3` and keep each flag's typed value, reason and metadata. Servers that still send the older response keep working. Existing getters, callbacks and `$feature_flag_called` properties return the same values as before: a number or object value reads as `True`, with the value as its payload. Add `get_boolean_value`, `get_string_value`, `get_number_value` and `get_object_value` to the `evaluate_flags()` result, each with a `_details` form that also returns the reason and 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. diff --git a/posthog/__init__.py b/posthog/__init__.py index 5d6ae1193..db90d1eaf 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -81,6 +81,8 @@ BeforeSendCallback as BeforeSendCallback, FeatureFlag as FeatureFlag, FeatureFlagEvaluationRuntime as FeatureFlagEvaluationRuntime, + FlagEvaluationDetails as FlagEvaluationDetails, + FlagEvaluationErrorCode as FlagEvaluationErrorCode, FlagValue as FlagValue, FlagsAndPayloads as FlagsAndPayloads, ) diff --git a/posthog/_async_request.py b/posthog/_async_request.py index ae750de9a..e989c3983 100644 --- a/posthog/_async_request.py +++ b/posthog/_async_request.py @@ -149,7 +149,7 @@ async def async_flags( _serialize_flags_body, api_key, request_data ) response = await http_client.post( - "/flags/?v=2", content=data, headers=headers, timeout=timeout + "/flags/?v=3", content=data, headers=headers, timeout=timeout ) return await asyncio.to_thread(_process_flags_response, response) except httpx_module.TransportError: diff --git a/posthog/async_client.py b/posthog/async_client.py index 3f429e81a..bb5d2d50d 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -968,6 +968,7 @@ async def evaluate_flags( ), locally_evaluated=False, has_experiment=_metadata_has_experiment(metadata), + details=detail, ) except QuotaLimitError: self.log.warning( diff --git a/posthog/client.py b/posthog/client.py index 2bb16c492..d79917af6 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -4635,6 +4635,7 @@ def evaluate_flags( ), locally_evaluated=False, has_experiment=_metadata_has_experiment(detail.metadata), + details=detail, ) except QuotaLimitError as e: self.log.warning(f"[FEATURE FLAGS] Quota limit exceeded: {e}") diff --git a/posthog/feature_flag_evaluations.py b/posthog/feature_flag_evaluations.py index c260fbde7..7e2884cd9 100644 --- a/posthog/feature_flag_evaluations.py +++ b/posthog/feature_flag_evaluations.py @@ -8,7 +8,12 @@ from dataclasses import dataclass from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Union -from posthog.types import FlagValue +from posthog.types import ( + FeatureFlag, + FlagEvaluationDetails, + FlagValue, + _resolve_flag_details, +) @dataclass(frozen=True) @@ -26,6 +31,9 @@ class _EvaluatedFlagRecord: # Server-reported signal for whether the flag is linked to an experiment. # ``None`` when the server did not report it (older deployments). has_experiment: Optional[bool] = None + # The parsed /flags record, with its typed value, reason and metadata. + # ``None`` for a locally evaluated flag. + details: Optional[FeatureFlag] = None @dataclass @@ -123,6 +131,92 @@ 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. ``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. 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 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_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. + ``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. @@ -215,6 +309,18 @@ 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) + details = None + if flag is not None: + details = flag.details or FeatureFlag.from_value_and_payload( + key, flag.variant if flag.variant is not None else flag.enabled, None + ) + return _resolve_flag_details(key, details, default_value, value_type) + def _record_access(self, key: str) -> None: self._accessed.add(key) diff --git a/posthog/request.py b/posthog/request.py index 76df1fdc9..c28d987c5 100644 --- a/posthog/request.py +++ b/posthog/request.py @@ -329,7 +329,7 @@ def flags( res = post( api_key, host, - "/flags/?v=2", + "/flags/?v=3", gzip, timeout, session=_get_flags_session(), diff --git a/posthog/test/fixtures/feature_flag_rules_v2/SOURCE.json b/posthog/test/fixtures/feature_flag_rules_v2/SOURCE.json new file mode 100644 index 000000000..0eee1fe70 --- /dev/null +++ b/posthog/test/fixtures/feature_flag_rules_v2/SOURCE.json @@ -0,0 +1,27 @@ +{ + "description": "Copied unchanged from the released feature flag rules v2 contract in PostHog/posthog-sdk-test-harness. Do not edit these files; copy a newer release instead.", + "repository": "https://github.com/PostHog/posthog-sdk-test-harness", + "release": "1.13.1", + "revision": "e32f08bd30fab57ce543da5808a668d0fda697b0", + "contract_directory": "contracts/feature_flag_rules_v2", + "contract_version": "2.3.1", + "checksum_index_sha256": "8fbf9ae84ae29ee0b09150a9c8cc378942c716a287a202d6a057131467407c6e", + "files": { + "readers.json": { + "source_path": "fixtures/wire/readers.json", + "sha256": "07499c5a7ca7467968156ee16fa95bdeb9018c0b841c4ec8f5bffc61d72de4fa" + }, + "responses.json": { + "source_path": "fixtures/wire/responses.json", + "sha256": "e34659b62facbc594bdc1ce6c0f127ff25d94672da332670b383d729f45e5960" + }, + "legacy_projection.json": { + "source_path": "corpus/legacy_projection.json", + "sha256": "536c8386eefa75721d113bd8718ddeb7fb1aad3b4f2cce1fbcf516a346a16e26" + }, + "v2_value_evaluation.json": { + "source_path": "corpus/v2_value_evaluation.json", + "sha256": "aaddd05e47fc5a4ae07ea42ada5a659798f4b5fc8d0c0827f6a68e4daf68c128" + } + } +} diff --git a/posthog/test/fixtures/feature_flag_rules_v2/legacy_projection.json b/posthog/test/fixtures/feature_flag_rules_v2/legacy_projection.json new file mode 100644 index 000000000..3a144281c --- /dev/null +++ b/posthog/test/fixtures/feature_flag_rules_v2/legacy_projection.json @@ -0,0 +1,661 @@ +{ + "corpus_version": "1.1.0", + "config_version": 1, + "description": "Projection matrix from a version 1 evaluation outcome to each response protocol version. Response protocol versions are independent of the config version, which is 1 for every case.", + "response_protocols": { + "flags_v2": { + "endpoint": "/flags", + "selected_by": "v=2 or higher, or /decide with v=4 or higher", + "shape": "flags map of evaluation details with reason and metadata; errorsWhileComputingFlags at the top level" + }, + "flags_v1": { + "endpoint": "/flags", + "selected_by": "v=1 or absent, or /decide with v=3", + "shape": "featureFlags map of every evaluated flag plus featureFlagPayloads for flags with a payload; errorsWhileComputingFlags at the top level" + }, + "decide_v2": { + "endpoint": "/decide", + "selected_by": "v=2", + "shape": "featureFlags map of enabled flags only" + }, + "decide_v1": { + "endpoint": "/decide", + "selected_by": "v=1 or absent", + "shape": "featureFlags list of enabled flag keys" + } + }, + "asserted_fields": "flags_v2 records key, enabled, variant, failed when true, reason.code, reason.condition_index, metadata.id, metadata.version, and metadata.payload. Live responses carry additional request-level and metadata fields that this corpus does not pin.", + "cases": [ + { + "id": "projection.boolean_true", + "config_version": 1, + "outcome": { + "key": "beta-search", + "flag_id": 101, + "flag_version": 3, + "enabled": true, + "variant": null, + "payload": null, + "failed": false, + "omitted": false, + "reason": { + "code": "condition_match", + "condition_index": 0 + } + }, + "projections": { + "flags_v2": { + "response_version": 2, + "errorsWhileComputingFlags": false, + "flags": { + "beta-search": { + "key": "beta-search", + "enabled": true, + "variant": null, + "reason": { + "code": "condition_match", + "condition_index": 0 + }, + "metadata": { + "id": 101, + "version": 3, + "payload": null + } + } + } + }, + "flags_v1": { + "response_version": 1, + "errorsWhileComputingFlags": false, + "featureFlags": { + "beta-search": true + }, + "featureFlagPayloads": {} + }, + "decide_v2": { + "response_version": 2, + "featureFlags": { + "beta-search": true + } + }, + "decide_v1": { + "response_version": 1, + "featureFlags": [ + "beta-search" + ] + } + } + }, + { + "id": "projection.boolean_true_with_payload", + "config_version": 1, + "outcome": { + "key": "beta-search", + "flag_id": 101, + "flag_version": 3, + "enabled": true, + "variant": null, + "payload": "{\"cta\":\"Try beta search\"}", + "failed": false, + "omitted": false, + "reason": { + "code": "condition_match", + "condition_index": 0 + } + }, + "projections": { + "flags_v2": { + "response_version": 2, + "errorsWhileComputingFlags": false, + "flags": { + "beta-search": { + "key": "beta-search", + "enabled": true, + "variant": null, + "reason": { + "code": "condition_match", + "condition_index": 0 + }, + "metadata": { + "id": 101, + "version": 3, + "payload": "{\"cta\":\"Try beta search\"}" + } + } + } + }, + "flags_v1": { + "response_version": 1, + "errorsWhileComputingFlags": false, + "featureFlags": { + "beta-search": true + }, + "featureFlagPayloads": { + "beta-search": "{\"cta\":\"Try beta search\"}" + } + }, + "decide_v2": { + "response_version": 2, + "featureFlags": { + "beta-search": true + } + }, + "decide_v1": { + "response_version": 1, + "featureFlags": [ + "beta-search" + ] + } + }, + "notes": "The payload is the stored JSON string; no response version parses it." + }, + { + "id": "projection.boolean_false_targeting_miss", + "config_version": 1, + "outcome": { + "key": "beta-search", + "flag_id": 101, + "flag_version": 3, + "enabled": false, + "variant": null, + "payload": null, + "failed": false, + "omitted": false, + "reason": { + "code": "no_condition_match", + "condition_index": 0 + } + }, + "projections": { + "flags_v2": { + "response_version": 2, + "errorsWhileComputingFlags": false, + "flags": { + "beta-search": { + "key": "beta-search", + "enabled": false, + "variant": null, + "reason": { + "code": "no_condition_match", + "condition_index": 0 + }, + "metadata": { + "id": 101, + "version": 3, + "payload": null + } + } + } + }, + "flags_v1": { + "response_version": 1, + "errorsWhileComputingFlags": false, + "featureFlags": { + "beta-search": false + }, + "featureFlagPayloads": {} + }, + "decide_v2": { + "response_version": 2, + "featureFlags": {} + }, + "decide_v1": { + "response_version": 1, + "featureFlags": [] + } + }, + "notes": "Every evaluated flag appears in the flags_v1 map, including false results. decide_v2 and decide_v1 carry enabled flags only." + }, + { + "id": "projection.boolean_false_out_of_rollout", + "config_version": 1, + "outcome": { + "key": "beta-search-rollout", + "flag_id": 102, + "flag_version": 1, + "enabled": false, + "variant": null, + "payload": null, + "failed": false, + "omitted": false, + "reason": { + "code": "out_of_rollout_bound", + "condition_index": 0 + } + }, + "projections": { + "flags_v2": { + "response_version": 2, + "errorsWhileComputingFlags": false, + "flags": { + "beta-search-rollout": { + "key": "beta-search-rollout", + "enabled": false, + "variant": null, + "reason": { + "code": "out_of_rollout_bound", + "condition_index": 0 + }, + "metadata": { + "id": 102, + "version": 1, + "payload": null + } + } + } + }, + "flags_v1": { + "response_version": 1, + "errorsWhileComputingFlags": false, + "featureFlags": { + "beta-search-rollout": false + }, + "featureFlagPayloads": {} + }, + "decide_v2": { + "response_version": 2, + "featureFlags": {} + }, + "decide_v1": { + "response_version": 1, + "featureFlags": [] + } + } + }, + { + "id": "projection.variant", + "config_version": 1, + "outcome": { + "key": "checkout-layout", + "flag_id": 106, + "flag_version": 5, + "enabled": true, + "variant": "control", + "payload": null, + "failed": false, + "omitted": false, + "reason": { + "code": "condition_match", + "condition_index": 1 + } + }, + "projections": { + "flags_v2": { + "response_version": 2, + "errorsWhileComputingFlags": false, + "flags": { + "checkout-layout": { + "key": "checkout-layout", + "enabled": true, + "variant": "control", + "reason": { + "code": "condition_match", + "condition_index": 1 + }, + "metadata": { + "id": 106, + "version": 5, + "payload": null + } + } + } + }, + "flags_v1": { + "response_version": 1, + "errorsWhileComputingFlags": false, + "featureFlags": { + "checkout-layout": "control" + }, + "featureFlagPayloads": {} + }, + "decide_v2": { + "response_version": 2, + "featureFlags": { + "checkout-layout": "control" + } + }, + "decide_v1": { + "response_version": 1, + "featureFlags": [ + "checkout-layout" + ] + } + }, + "notes": "A variant projects as its string in every map and as membership in the list." + }, + { + "id": "projection.variant_with_payload", + "config_version": 1, + "outcome": { + "key": "checkout-layout", + "flag_id": 106, + "flag_version": 5, + "enabled": true, + "variant": "compact", + "payload": "{\"density\":\"compact\"}", + "failed": false, + "omitted": false, + "reason": { + "code": "condition_match", + "condition_index": 1 + } + }, + "projections": { + "flags_v2": { + "response_version": 2, + "errorsWhileComputingFlags": false, + "flags": { + "checkout-layout": { + "key": "checkout-layout", + "enabled": true, + "variant": "compact", + "reason": { + "code": "condition_match", + "condition_index": 1 + }, + "metadata": { + "id": 106, + "version": 5, + "payload": "{\"density\":\"compact\"}" + } + } + } + }, + "flags_v1": { + "response_version": 1, + "errorsWhileComputingFlags": false, + "featureFlags": { + "checkout-layout": "compact" + }, + "featureFlagPayloads": { + "checkout-layout": "{\"density\":\"compact\"}" + } + }, + "decide_v2": { + "response_version": 2, + "featureFlags": { + "checkout-layout": "compact" + } + }, + "decide_v1": { + "response_version": 1, + "featureFlags": [ + "checkout-layout" + ] + } + } + }, + { + "id": "projection.holdout_variant", + "config_version": 1, + "outcome": { + "key": "checkout-experiment", + "flag_id": 113, + "flag_version": 4, + "enabled": true, + "variant": "holdout-7", + "payload": null, + "failed": false, + "omitted": false, + "reason": { + "code": "holdout_condition_value", + "condition_index": null + } + }, + "projections": { + "flags_v2": { + "response_version": 2, + "errorsWhileComputingFlags": false, + "flags": { + "checkout-experiment": { + "key": "checkout-experiment", + "enabled": true, + "variant": "holdout-7", + "reason": { + "code": "holdout_condition_value", + "condition_index": null + }, + "metadata": { + "id": 113, + "version": 4, + "payload": null + } + } + } + }, + "flags_v1": { + "response_version": 1, + "errorsWhileComputingFlags": false, + "featureFlags": { + "checkout-experiment": "holdout-7" + }, + "featureFlagPayloads": {} + }, + "decide_v2": { + "response_version": 2, + "featureFlags": { + "checkout-experiment": "holdout-7" + } + }, + "decide_v1": { + "response_version": 1, + "featureFlags": [ + "checkout-experiment" + ] + } + }, + "notes": "The synthesized holdout variant projects like any other string value." + }, + { + "id": "projection.no_group_type", + "config_version": 1, + "outcome": { + "key": "org-dashboard", + "flag_id": 109, + "flag_version": 2, + "enabled": false, + "variant": null, + "payload": null, + "failed": false, + "omitted": false, + "reason": { + "code": "no_group_type", + "condition_index": 0 + } + }, + "projections": { + "flags_v2": { + "response_version": 2, + "errorsWhileComputingFlags": false, + "flags": { + "org-dashboard": { + "key": "org-dashboard", + "enabled": false, + "variant": null, + "reason": { + "code": "no_group_type", + "condition_index": 0 + }, + "metadata": { + "id": 109, + "version": 2, + "payload": null + } + } + } + }, + "flags_v1": { + "response_version": 1, + "errorsWhileComputingFlags": false, + "featureFlags": { + "org-dashboard": false + }, + "featureFlagPayloads": {} + }, + "decide_v2": { + "response_version": 2, + "featureFlags": {} + }, + "decide_v1": { + "response_version": 1, + "featureFlags": [] + } + } + }, + { + "id": "projection.missing_dependency", + "config_version": 1, + "outcome": { + "key": "beta-search-suggestions-orphan", + "flag_id": 112, + "flag_version": 1, + "enabled": false, + "variant": null, + "payload": null, + "failed": false, + "omitted": false, + "reason": { + "code": "missing_dependency", + "condition_index": null + } + }, + "projections": { + "flags_v2": { + "response_version": 2, + "errorsWhileComputingFlags": false, + "flags": { + "beta-search-suggestions-orphan": { + "key": "beta-search-suggestions-orphan", + "enabled": false, + "variant": null, + "reason": { + "code": "missing_dependency", + "condition_index": null + }, + "metadata": { + "id": 112, + "version": 1, + "payload": null + } + } + } + }, + "flags_v1": { + "response_version": 1, + "errorsWhileComputingFlags": false, + "featureFlags": { + "beta-search-suggestions-orphan": false + }, + "featureFlagPayloads": {} + }, + "decide_v2": { + "response_version": 2, + "featureFlags": {} + }, + "decide_v1": { + "response_version": 1, + "featureFlags": [] + } + }, + "notes": "A missing dependency is a closed false result, not an evaluation failure, so errorsWhileComputingFlags stays false." + }, + { + "id": "projection.failed_evaluation", + "config_version": 1, + "outcome": { + "key": "beta-search", + "flag_id": 101, + "flag_version": 3, + "enabled": false, + "variant": null, + "payload": null, + "failed": true, + "omitted": false, + "reason": { + "code": "timeout", + "condition_index": null + } + }, + "projections": { + "flags_v2": { + "response_version": 2, + "errorsWhileComputingFlags": true, + "flags": { + "beta-search": { + "key": "beta-search", + "enabled": false, + "variant": null, + "reason": { + "code": "timeout", + "condition_index": null + }, + "metadata": { + "id": 101, + "version": 3, + "payload": null + }, + "failed": true + } + } + }, + "flags_v1": { + "response_version": 1, + "errorsWhileComputingFlags": true, + "featureFlags": { + "beta-search": false + }, + "featureFlagPayloads": {} + }, + "decide_v2": { + "response_version": 2, + "featureFlags": {} + }, + "decide_v1": { + "response_version": 1, + "featureFlags": [] + } + }, + "notes": "A failed evaluation projects as false in the flags_v1 map with errorsWhileComputingFlags true, is omitted from decide_v2 and decide_v1, and carries failed true in flags_v2. The reason code is representative." + }, + { + "id": "projection.inactive_omitted", + "config_version": 1, + "outcome": { + "key": "retired-banner", + "flag_id": 114, + "flag_version": 9, + "enabled": false, + "variant": null, + "payload": null, + "failed": false, + "omitted": true, + "reason": { + "code": "no_condition_match", + "condition_index": null + } + }, + "projections": { + "flags_v2": { + "response_version": 2, + "errorsWhileComputingFlags": false, + "flags": {} + }, + "flags_v1": { + "response_version": 1, + "errorsWhileComputingFlags": false, + "featureFlags": {}, + "featureFlagPayloads": {} + }, + "decide_v2": { + "response_version": 2, + "featureFlags": {} + }, + "decide_v1": { + "response_version": 1, + "featureFlags": [] + } + }, + "notes": "Inactive, archived, and deleted flags are omitted from every response version." + } + ] +} diff --git a/posthog/test/fixtures/feature_flag_rules_v2/readers.json b/posthog/test/fixtures/feature_flag_rules_v2/readers.json new file mode 100644 index 000000000..1e24b8e23 --- /dev/null +++ b/posthog/test/fixtures/feature_flag_rules_v2/readers.json @@ -0,0 +1,1087 @@ +{ + "fixture_version": "1.0.0", + "schema": "schemas/flags_response_v3.schema.json", + "templates": { + "targeting_match_targeted_release": { + "errorsWhileComputingFlags": false, + "flags": { + "garden-layout": { + "key": "garden-layout", + "value": false, + "reason": { + "code": "targeting_match", + "condition_index": 0 + }, + "metadata": { + "id": 201, + "version": 7, + "config_version": 2, + "has_experiment": false, + "payload": null, + "rule_type": "targeted_release", + "rule_id": "10000000-0000-4000-8000-000000000001" + } + } + }, + "requestId": "20000000-0000-4000-8000-000000000002", + "evaluatedAt": 1800000000000 + }, + "targeting_match_percentage_rollout": { + "errorsWhileComputingFlags": false, + "flags": { + "garden-layout": { + "key": "garden-layout", + "value": false, + "reason": { + "code": "targeting_match", + "condition_index": 0 + }, + "metadata": { + "id": 201, + "version": 7, + "config_version": 2, + "has_experiment": false, + "payload": null, + "rule_type": "percentage_rollout", + "rule_id": "10000000-0000-4000-8000-000000000001" + } + } + }, + "requestId": "20000000-0000-4000-8000-000000000002", + "evaluatedAt": 1800000000000 + }, + "experiment_split": { + "errorsWhileComputingFlags": false, + "flags": { + "garden-layout": { + "key": "garden-layout", + "value": false, + "reason": { + "code": "experiment_split", + "condition_index": 0 + }, + "metadata": { + "id": 201, + "version": 7, + "config_version": 2, + "has_experiment": true, + "payload": null, + "rule_type": "experiment", + "rule_id": "10000000-0000-4000-8000-000000000001", + "experiment_id": 301, + "variant_key": "arm_a" + } + } + }, + "requestId": "20000000-0000-4000-8000-000000000002", + "evaluatedAt": 1800000000000 + }, + "rollout_miss_percentage_rollout": { + "errorsWhileComputingFlags": false, + "flags": { + "garden-layout": { + "key": "garden-layout", + "value": false, + "reason": { + "code": "rollout_miss", + "condition_index": 0 + }, + "metadata": { + "id": 201, + "version": 7, + "config_version": 2, + "has_experiment": false, + "payload": null, + "rule_type": "percentage_rollout", + "rule_id": "10000000-0000-4000-8000-000000000001" + } + } + }, + "requestId": "20000000-0000-4000-8000-000000000002", + "evaluatedAt": 1800000000000 + }, + "rollout_miss_experiment": { + "errorsWhileComputingFlags": false, + "flags": { + "garden-layout": { + "key": "garden-layout", + "value": false, + "reason": { + "code": "rollout_miss", + "condition_index": 0 + }, + "metadata": { + "id": 201, + "version": 7, + "config_version": 2, + "has_experiment": true, + "payload": null, + "rule_type": "experiment", + "rule_id": "10000000-0000-4000-8000-000000000001", + "experiment_id": 301 + } + } + }, + "requestId": "20000000-0000-4000-8000-000000000002", + "evaluatedAt": 1800000000000 + }, + "experiment_paused": { + "errorsWhileComputingFlags": false, + "flags": { + "garden-layout": { + "key": "garden-layout", + "value": false, + "reason": { + "code": "experiment_paused", + "condition_index": 0 + }, + "metadata": { + "id": 201, + "version": 7, + "config_version": 2, + "has_experiment": true, + "payload": null, + "rule_type": "experiment", + "rule_id": "10000000-0000-4000-8000-000000000001", + "experiment_id": 301 + } + } + }, + "requestId": "20000000-0000-4000-8000-000000000002", + "evaluatedAt": 1800000000000 + }, + "holdout": { + "errorsWhileComputingFlags": false, + "flags": { + "garden-layout": { + "key": "garden-layout", + "value": false, + "reason": { + "code": "holdout", + "condition_index": 0 + }, + "metadata": { + "id": 201, + "version": 7, + "config_version": 2, + "has_experiment": true, + "payload": null, + "rule_type": "experiment", + "rule_id": "10000000-0000-4000-8000-000000000001", + "experiment_id": 301, + "holdout_id": 401 + } + } + }, + "requestId": "20000000-0000-4000-8000-000000000002", + "evaluatedAt": 1800000000000 + }, + "no_rule_match": { + "errorsWhileComputingFlags": false, + "flags": { + "garden-layout": { + "key": "garden-layout", + "value": false, + "reason": { + "code": "no_rule_match", + "condition_index": null + }, + "metadata": { + "id": 201, + "version": 7, + "config_version": 2, + "has_experiment": false, + "payload": null + } + } + }, + "requestId": "20000000-0000-4000-8000-000000000002", + "evaluatedAt": 1800000000000 + }, + "missing_group_key": { + "errorsWhileComputingFlags": false, + "flags": { + "garden-layout": { + "key": "garden-layout", + "value": null, + "reason": { + "code": "missing_group_key", + "condition_index": null + }, + "metadata": { + "id": 201, + "version": 7, + "config_version": 2, + "has_experiment": false, + "payload": null + } + } + }, + "requestId": "20000000-0000-4000-8000-000000000002", + "evaluatedAt": 1800000000000 + }, + "dependency_error": { + "errorsWhileComputingFlags": true, + "flags": { + "garden-layout": { + "key": "garden-layout", + "value": null, + "reason": { + "code": "dependency_error", + "condition_index": null + }, + "metadata": { + "id": 201, + "version": 7, + "config_version": 2, + "has_experiment": false, + "payload": null + }, + "failed": true + } + }, + "requestId": "20000000-0000-4000-8000-000000000002", + "evaluatedAt": 1800000000000 + }, + "error": { + "errorsWhileComputingFlags": true, + "flags": { + "garden-layout": { + "key": "garden-layout", + "value": null, + "reason": { + "code": "error", + "condition_index": null + }, + "metadata": { + "id": 201, + "version": 7, + "config_version": 2, + "has_experiment": false, + "payload": null + }, + "failed": true + } + }, + "requestId": "20000000-0000-4000-8000-000000000002", + "evaluatedAt": 1800000000000 + }, + "v1": { + "errorsWhileComputingFlags": false, + "flags": { + "garden-layout": { + "key": "garden-layout", + "value": "compact", + "reason": { + "code": "condition_match", + "condition_index": 0 + }, + "metadata": { + "id": 202, + "version": 9, + "config_version": 1, + "has_experiment": false, + "variant_key": "compact", + "payload": "{\"columns\":2}" + } + } + }, + "requestId": "20000000-0000-4000-8000-000000000002", + "evaluatedAt": 1800000000000 + }, + "omitted": { + "errorsWhileComputingFlags": false, + "flags": {}, + "requestId": "20000000-0000-4000-8000-000000000002", + "evaluatedAt": 1800000000000 + }, + "old_server": { + "errorsWhileComputingFlags": false, + "flags": { + "garden-layout": { + "key": "garden-layout", + "enabled": true, + "variant": "compact", + "reason": { + "code": "condition_match", + "condition_index": 0 + }, + "metadata": { + "id": 202, + "version": 9, + "config_version": 2, + "rule_type": "experiment", + "rule_id": "10000000-0000-4000-8000-000000000001", + "experiment_id": 301, + "variant_key": "arm_a" + } + } + }, + "requestId": "20000000-0000-4000-8000-000000000002", + "evaluatedAt": 1800000000000 + } + }, + "cases": [ + { + "id": "readers.complete_split", + "template": "experiment_split", + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": false, + "value": false, + "config_version": 2, + "experiment_exposure": true, + "error_code": null + }, + "producer_expected": "valid" + }, + { + "id": "readers.partial_split_missing_config_version", + "template": "experiment_split", + "remove": [ + "/flags/garden-layout/metadata/config_version" + ], + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": true, + "value": false, + "config_version": null, + "experiment_exposure": false, + "error_code": null + }, + "producer_failure": { + "layer": "schema", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "config_version" + }, + "producer_expected": "invalid" + }, + { + "id": "readers.partial_split_missing_rule_type", + "template": "experiment_split", + "remove": [ + "/flags/garden-layout/metadata/rule_type" + ], + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": true, + "value": false, + "config_version": 2, + "experiment_exposure": false, + "error_code": null + }, + "producer_failure": { + "layer": "schema", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "rule_type" + }, + "producer_expected": "invalid" + }, + { + "id": "readers.partial_split_missing_rule_id", + "template": "experiment_split", + "remove": [ + "/flags/garden-layout/metadata/rule_id" + ], + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": true, + "value": false, + "config_version": 2, + "experiment_exposure": false, + "error_code": null + }, + "producer_failure": { + "layer": "schema", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "rule_id" + }, + "producer_expected": "invalid" + }, + { + "id": "readers.partial_split_missing_experiment_id", + "template": "experiment_split", + "remove": [ + "/flags/garden-layout/metadata/experiment_id" + ], + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": true, + "value": false, + "config_version": 2, + "experiment_exposure": false, + "error_code": null + }, + "producer_failure": { + "layer": "schema", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "experiment_id" + }, + "producer_expected": "invalid" + }, + { + "id": "readers.partial_split_missing_variant_key", + "template": "experiment_split", + "remove": [ + "/flags/garden-layout/metadata/variant_key" + ], + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": true, + "value": false, + "config_version": 2, + "experiment_exposure": false, + "error_code": null + }, + "producer_failure": { + "layer": "schema", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "variant_key" + }, + "producer_expected": "invalid" + }, + { + "id": "readers.split_wrong_type_config_version", + "template": "experiment_split", + "set": { + "/flags/garden-layout/metadata/config_version": "2" + }, + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": true, + "value": true, + "config_version": null, + "experiment_exposure": false, + "error_code": "PARSE_ERROR" + }, + "producer_expected": "invalid", + "producer_failure": { + "layer": "schema", + "keyword": "enum", + "instance_path": "/flags/garden-layout/metadata/config_version" + } + }, + { + "id": "readers.split_wrong_type_rule_type", + "template": "experiment_split", + "set": { + "/flags/garden-layout/metadata/rule_type": 2 + }, + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": true, + "value": true, + "config_version": null, + "experiment_exposure": false, + "error_code": "PARSE_ERROR" + }, + "producer_expected": "invalid", + "producer_failure": { + "layer": "schema", + "keyword": "enum", + "instance_path": "/flags/garden-layout/metadata/rule_type" + } + }, + { + "id": "readers.split_wrong_type_rule_id", + "template": "experiment_split", + "set": { + "/flags/garden-layout/metadata/rule_id": 3 + }, + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": true, + "value": true, + "config_version": null, + "experiment_exposure": false, + "error_code": "PARSE_ERROR" + }, + "producer_expected": "invalid", + "producer_failure": { + "layer": "schema", + "keyword": "type", + "instance_path": "/flags/garden-layout/metadata/rule_id" + } + }, + { + "id": "readers.split_wrong_type_experiment_id", + "template": "experiment_split", + "set": { + "/flags/garden-layout/metadata/experiment_id": "301" + }, + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": true, + "value": true, + "config_version": null, + "experiment_exposure": false, + "error_code": "PARSE_ERROR" + }, + "producer_expected": "invalid", + "producer_failure": { + "layer": "schema", + "keyword": "type", + "instance_path": "/flags/garden-layout/metadata/experiment_id" + } + }, + { + "id": "readers.split_wrong_type_variant_key", + "template": "experiment_split", + "set": { + "/flags/garden-layout/metadata/variant_key": true + }, + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": true, + "value": true, + "config_version": null, + "experiment_exposure": false, + "error_code": "PARSE_ERROR" + }, + "producer_expected": "invalid", + "producer_failure": { + "layer": "schema", + "keyword": "type", + "instance_path": "/flags/garden-layout/metadata/variant_key" + } + }, + { + "id": "readers.wrong_rule_type_literal", + "template": "experiment_split", + "set": { + "/flags/garden-layout/metadata/rule_type": "percentage_rollout" + }, + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": true, + "value": false, + "config_version": 2, + "experiment_exposure": false, + "error_code": null + }, + "producer_expected": "invalid", + "producer_failure": { + "layer": "schema", + "keyword": "const", + "instance_path": "/flags/garden-layout/metadata/rule_type" + } + }, + { + "id": "readers.unknown_fields", + "template": "experiment_split", + "set": { + "/future_envelope": { + "nested": true + }, + "/flags/garden-layout/future_record": [], + "/flags/garden-layout/reason/future_reason": 3, + "/flags/garden-layout/metadata/future_metadata": "ignored" + }, + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": false, + "value": false, + "config_version": 2, + "experiment_exposure": true, + "error_code": null + }, + "producer_expected": "invalid", + "producer_failure": { + "layer": "schema", + "keyword": "additionalProperties", + "instance_path": "/flags/garden-layout" + } + }, + { + "id": "readers.old_server_variant", + "template": "old_server", + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": false, + "value": "compact", + "config_version": 1, + "experiment_exposure": false, + "error_code": null + }, + "producer_expected": "invalid", + "producer_failure": { + "layer": "schema", + "keyword": "required", + "instance_path": "/flags/garden-layout", + "message_contains": "value" + } + }, + { + "id": "readers.old_server_boolean", + "template": "old_server", + "remove": [ + "/flags/garden-layout/variant" + ], + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": false, + "value": true, + "config_version": 1, + "experiment_exposure": false, + "error_code": null + }, + "producer_expected": "invalid", + "producer_failure": { + "layer": "schema", + "keyword": "required", + "instance_path": "/flags/garden-layout", + "message_contains": "value" + } + }, + { + "id": "readers.old_server_null_variant", + "template": "old_server", + "set": { + "/flags/garden-layout/variant": null + }, + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": false, + "value": true, + "config_version": 1, + "experiment_exposure": false, + "error_code": null + }, + "producer_expected": "invalid", + "producer_failure": { + "layer": "schema", + "keyword": "required", + "instance_path": "/flags/garden-layout", + "message_contains": "value" + } + }, + { + "id": "readers.old_server_false", + "template": "old_server", + "set": { + "/flags/garden-layout/enabled": false + }, + "remove": [ + "/flags/garden-layout/variant" + ], + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": false, + "value": false, + "config_version": 1, + "experiment_exposure": false, + "error_code": null + }, + "producer_expected": "invalid", + "producer_failure": { + "layer": "schema", + "keyword": "required", + "instance_path": "/flags/garden-layout", + "message_contains": "value" + } + }, + { + "id": "readers.v3_value_takes_precedence", + "template": "experiment_split", + "set": { + "/flags/garden-layout/enabled": true, + "/flags/garden-layout/variant": "wrong-channel" + }, + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": false, + "value": false, + "config_version": 2, + "experiment_exposure": true, + "error_code": null + }, + "producer_expected": "invalid", + "producer_failure": { + "layer": "schema", + "keyword": "additionalProperties", + "instance_path": "/flags/garden-layout" + } + }, + { + "id": "readers.missing_flag", + "template": "omitted", + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": false, + "value": false, + "config_version": null, + "experiment_exposure": false, + "error_code": "FLAG_NOT_FOUND" + }, + "producer_expected": "valid" + }, + { + "id": "readers.targeting_match_targeted_release", + "template": "targeting_match_targeted_release", + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": false, + "value": false, + "config_version": 2, + "experiment_exposure": false, + "error_code": null + }, + "producer_expected": "valid" + }, + { + "id": "readers.targeting_match_percentage_rollout", + "template": "targeting_match_percentage_rollout", + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": false, + "value": false, + "config_version": 2, + "experiment_exposure": false, + "error_code": null + }, + "producer_expected": "valid" + }, + { + "id": "readers.rollout_miss_percentage_rollout", + "template": "rollout_miss_percentage_rollout", + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": false, + "value": false, + "config_version": 2, + "experiment_exposure": false, + "error_code": null + }, + "producer_expected": "valid" + }, + { + "id": "readers.rollout_miss_experiment", + "template": "rollout_miss_experiment", + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": false, + "value": false, + "config_version": 2, + "experiment_exposure": false, + "error_code": null + }, + "producer_expected": "valid" + }, + { + "id": "readers.experiment_paused", + "template": "experiment_paused", + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": false, + "value": false, + "config_version": 2, + "experiment_exposure": false, + "error_code": null + }, + "producer_expected": "valid" + }, + { + "id": "readers.holdout", + "template": "holdout", + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": false, + "value": false, + "config_version": 2, + "experiment_exposure": false, + "error_code": null + }, + "producer_expected": "valid" + }, + { + "id": "readers.holdout_variant_key", + "template": "holdout", + "set": { + "/flags/garden-layout/metadata/variant_key": "arm_a" + }, + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": false, + "value": false, + "config_version": 2, + "experiment_exposure": false, + "error_code": null + }, + "producer_expected": "invalid", + "producer_failure": { + "layer": "presence", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "readers.no_rule_match", + "template": "no_rule_match", + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": false, + "value": false, + "config_version": 2, + "experiment_exposure": false, + "error_code": null + }, + "producer_expected": "valid" + }, + { + "id": "readers.missing_group_key", + "template": "missing_group_key", + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": false, + "value": false, + "config_version": 2, + "experiment_exposure": false, + "error_code": "INVALID_CONTEXT" + }, + "producer_expected": "valid" + }, + { + "id": "readers.dependency_error", + "template": "dependency_error", + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": false, + "value": false, + "config_version": 2, + "experiment_exposure": false, + "error_code": "GENERAL" + }, + "producer_expected": "valid" + }, + { + "id": "readers.error", + "template": "error", + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": false, + "value": false, + "config_version": 2, + "experiment_exposure": false, + "error_code": "GENERAL" + }, + "producer_expected": "valid" + }, + { + "id": "readers.error_with_value", + "template": "error", + "set": { + "/flags/garden-layout/value": false + }, + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": true, + "value": true, + "config_version": 2, + "experiment_exposure": false, + "error_code": "GENERAL" + }, + "producer_expected": "invalid", + "producer_failure": { + "layer": "schema", + "keyword": "type", + "instance_path": "/flags/garden-layout/value" + } + }, + { + "id": "readers.malformed_sibling_isolation", + "template": "experiment_split", + "set": { + "/flags/sibling": { + "key": "sibling", + "value": [], + "reason": { + "code": "no_rule_match", + "condition_index": null + }, + "metadata": { + "id": 203, + "version": 1, + "config_version": 2, + "has_experiment": false + } + } + }, + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": false, + "value": false, + "config_version": 2, + "experiment_exposure": true, + "error_code": null + }, + "sibling_expectations": { + "sibling": { + "error_code": "PARSE_ERROR", + "experiment_exposure": false + } + }, + "producer_expected": "invalid", + "producer_failure": { + "layer": "schema", + "keyword": "type", + "instance_path": "/flags/sibling/value" + } + }, + { + "id": "readers.partial_split_string_value", + "template": "experiment_split", + "set": { + "/flags/garden-layout/value": "usable-copy" + }, + "remove": [ + "/flags/garden-layout/metadata/variant_key" + ], + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": false, + "value": "usable-copy", + "config_version": 2, + "experiment_exposure": false, + "error_code": null + }, + "producer_expected": "invalid", + "producer_failure": { + "layer": "schema", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "variant_key" + } + }, + { + "id": "readers.v1_at_v3", + "template": "v1", + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": false, + "value": "compact", + "config_version": 1, + "experiment_exposure": false, + "error_code": null + }, + "producer_expected": "valid" + }, + { + "id": "readers.v1_failed", + "template": "v1", + "set": { + "/flags/garden-layout/failed": true, + "/flags/garden-layout/value": null, + "/flags/garden-layout/reason": { + "code": "missing_dependency", + "condition_index": null + }, + "/errorsWhileComputingFlags": true + }, + "remove": [ + "/flags/garden-layout/metadata/variant_key" + ], + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": true, + "value": true, + "config_version": 1, + "experiment_exposure": false, + "error_code": "GENERAL" + }, + "producer_expected": "valid" + }, + { + "id": "readers.integral_json_number_context", + "template": "experiment_split", + "set": { + "/flags/garden-layout/metadata/config_version": 2.0, + "/flags/garden-layout/metadata/experiment_id": 301.0 + }, + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": true, + "value": false, + "config_version": 2, + "experiment_exposure": true, + "error_code": null + }, + "producer_expected": "valid" + }, + { + "id": "readers.flag_key_seed", + "template": "omitted", + "set": { + "/flags/seed": { + "key": "seed", + "value": false, + "reason": { + "code": "experiment_split", + "condition_index": 0 + }, + "metadata": { + "id": 201, + "version": 7, + "config_version": 2, + "has_experiment": true, + "payload": null, + "rule_type": "experiment", + "rule_id": "10000000-0000-4000-8000-000000000001", + "experiment_id": 301, + "variant_key": "arm_a" + } + } + }, + "expected": "reader", + "reader": { + "requested_key": "seed", + "caller_default": true, + "value": false, + "config_version": 2, + "experiment_exposure": true, + "error_code": null + }, + "producer_expected": "valid" + } + ] +} diff --git a/posthog/test/fixtures/feature_flag_rules_v2/responses.json b/posthog/test/fixtures/feature_flag_rules_v2/responses.json new file mode 100644 index 000000000..959dc51be --- /dev/null +++ b/posthog/test/fixtures/feature_flag_rules_v2/responses.json @@ -0,0 +1,2235 @@ +{ + "fixture_version": "1.0.0", + "schema": "schemas/flags_response_v3.schema.json", + "templates": { + "targeting_match_targeted_release": { + "errorsWhileComputingFlags": false, + "flags": { + "garden-layout": { + "key": "garden-layout", + "value": false, + "reason": { + "code": "targeting_match", + "condition_index": 0 + }, + "metadata": { + "id": 201, + "version": 7, + "config_version": 2, + "has_experiment": false, + "payload": null, + "rule_type": "targeted_release", + "rule_id": "10000000-0000-4000-8000-000000000001" + } + } + }, + "requestId": "20000000-0000-4000-8000-000000000002", + "evaluatedAt": 1800000000000 + }, + "targeting_match_percentage_rollout": { + "errorsWhileComputingFlags": false, + "flags": { + "garden-layout": { + "key": "garden-layout", + "value": false, + "reason": { + "code": "targeting_match", + "condition_index": 0 + }, + "metadata": { + "id": 201, + "version": 7, + "config_version": 2, + "has_experiment": false, + "payload": null, + "rule_type": "percentage_rollout", + "rule_id": "10000000-0000-4000-8000-000000000001" + } + } + }, + "requestId": "20000000-0000-4000-8000-000000000002", + "evaluatedAt": 1800000000000 + }, + "experiment_split": { + "errorsWhileComputingFlags": false, + "flags": { + "garden-layout": { + "key": "garden-layout", + "value": false, + "reason": { + "code": "experiment_split", + "condition_index": 0 + }, + "metadata": { + "id": 201, + "version": 7, + "config_version": 2, + "has_experiment": true, + "payload": null, + "rule_type": "experiment", + "rule_id": "10000000-0000-4000-8000-000000000001", + "experiment_id": 301, + "variant_key": "arm_a" + } + } + }, + "requestId": "20000000-0000-4000-8000-000000000002", + "evaluatedAt": 1800000000000 + }, + "rollout_miss_percentage_rollout": { + "errorsWhileComputingFlags": false, + "flags": { + "garden-layout": { + "key": "garden-layout", + "value": false, + "reason": { + "code": "rollout_miss", + "condition_index": 0 + }, + "metadata": { + "id": 201, + "version": 7, + "config_version": 2, + "has_experiment": false, + "payload": null, + "rule_type": "percentage_rollout", + "rule_id": "10000000-0000-4000-8000-000000000001" + } + } + }, + "requestId": "20000000-0000-4000-8000-000000000002", + "evaluatedAt": 1800000000000 + }, + "rollout_miss_experiment": { + "errorsWhileComputingFlags": false, + "flags": { + "garden-layout": { + "key": "garden-layout", + "value": false, + "reason": { + "code": "rollout_miss", + "condition_index": 0 + }, + "metadata": { + "id": 201, + "version": 7, + "config_version": 2, + "has_experiment": true, + "payload": null, + "rule_type": "experiment", + "rule_id": "10000000-0000-4000-8000-000000000001", + "experiment_id": 301 + } + } + }, + "requestId": "20000000-0000-4000-8000-000000000002", + "evaluatedAt": 1800000000000 + }, + "experiment_paused": { + "errorsWhileComputingFlags": false, + "flags": { + "garden-layout": { + "key": "garden-layout", + "value": false, + "reason": { + "code": "experiment_paused", + "condition_index": 0 + }, + "metadata": { + "id": 201, + "version": 7, + "config_version": 2, + "has_experiment": true, + "payload": null, + "rule_type": "experiment", + "rule_id": "10000000-0000-4000-8000-000000000001", + "experiment_id": 301 + } + } + }, + "requestId": "20000000-0000-4000-8000-000000000002", + "evaluatedAt": 1800000000000 + }, + "holdout": { + "errorsWhileComputingFlags": false, + "flags": { + "garden-layout": { + "key": "garden-layout", + "value": false, + "reason": { + "code": "holdout", + "condition_index": 0 + }, + "metadata": { + "id": 201, + "version": 7, + "config_version": 2, + "has_experiment": true, + "payload": null, + "rule_type": "experiment", + "rule_id": "10000000-0000-4000-8000-000000000001", + "experiment_id": 301, + "holdout_id": 401 + } + } + }, + "requestId": "20000000-0000-4000-8000-000000000002", + "evaluatedAt": 1800000000000 + }, + "no_rule_match": { + "errorsWhileComputingFlags": false, + "flags": { + "garden-layout": { + "key": "garden-layout", + "value": false, + "reason": { + "code": "no_rule_match", + "condition_index": null + }, + "metadata": { + "id": 201, + "version": 7, + "config_version": 2, + "has_experiment": false, + "payload": null + } + } + }, + "requestId": "20000000-0000-4000-8000-000000000002", + "evaluatedAt": 1800000000000 + }, + "missing_group_key": { + "errorsWhileComputingFlags": false, + "flags": { + "garden-layout": { + "key": "garden-layout", + "value": null, + "reason": { + "code": "missing_group_key", + "condition_index": null + }, + "metadata": { + "id": 201, + "version": 7, + "config_version": 2, + "has_experiment": false, + "payload": null + } + } + }, + "requestId": "20000000-0000-4000-8000-000000000002", + "evaluatedAt": 1800000000000 + }, + "dependency_error": { + "errorsWhileComputingFlags": true, + "flags": { + "garden-layout": { + "key": "garden-layout", + "value": null, + "reason": { + "code": "dependency_error", + "condition_index": null + }, + "metadata": { + "id": 201, + "version": 7, + "config_version": 2, + "has_experiment": false, + "payload": null + }, + "failed": true + } + }, + "requestId": "20000000-0000-4000-8000-000000000002", + "evaluatedAt": 1800000000000 + }, + "error": { + "errorsWhileComputingFlags": true, + "flags": { + "garden-layout": { + "key": "garden-layout", + "value": null, + "reason": { + "code": "error", + "condition_index": null + }, + "metadata": { + "id": 201, + "version": 7, + "config_version": 2, + "has_experiment": false, + "payload": null + }, + "failed": true + } + }, + "requestId": "20000000-0000-4000-8000-000000000002", + "evaluatedAt": 1800000000000 + }, + "v1": { + "errorsWhileComputingFlags": false, + "flags": { + "garden-layout": { + "key": "garden-layout", + "value": "compact", + "reason": { + "code": "condition_match", + "condition_index": 0 + }, + "metadata": { + "id": 202, + "version": 9, + "config_version": 1, + "has_experiment": false, + "variant_key": "compact", + "payload": "{\"columns\":2}" + } + } + }, + "requestId": "20000000-0000-4000-8000-000000000002", + "evaluatedAt": 1800000000000 + }, + "omitted": { + "errorsWhileComputingFlags": false, + "flags": {}, + "requestId": "20000000-0000-4000-8000-000000000002", + "evaluatedAt": 1800000000000 + } + }, + "cases": [ + { + "id": "responses.targeting_match_targeted_release", + "template": "targeting_match_targeted_release", + "expected": "valid" + }, + { + "id": "responses.missing_id", + "template": "targeting_match_targeted_release", + "remove": [ + "/flags/garden-layout/metadata/id" + ], + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "id" + } + }, + { + "id": "responses.missing_version", + "template": "targeting_match_targeted_release", + "remove": [ + "/flags/garden-layout/metadata/version" + ], + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "version" + } + }, + { + "id": "responses.missing_config_version", + "template": "targeting_match_targeted_release", + "remove": [ + "/flags/garden-layout/metadata/config_version" + ], + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "config_version" + } + }, + { + "id": "responses.targeting_match_targeted_release_missing_has_experiment", + "template": "targeting_match_targeted_release", + "remove": [ + "/flags/garden-layout/metadata/has_experiment" + ], + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "has_experiment" + } + }, + { + "id": "responses.targeting_match_targeted_release_missing_rule_type", + "template": "targeting_match_targeted_release", + "remove": [ + "/flags/garden-layout/metadata/rule_type" + ], + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "rule_type" + } + }, + { + "id": "responses.targeting_match_targeted_release_missing_rule_id", + "template": "targeting_match_targeted_release", + "remove": [ + "/flags/garden-layout/metadata/rule_id" + ], + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "rule_id" + } + }, + { + "id": "responses.targeting_match_targeted_release_forbidden_experiment_id", + "template": "targeting_match_targeted_release", + "set": { + "/flags/garden-layout/metadata/experiment_id": 301 + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.targeting_match_targeted_release_forbidden_variant_key", + "template": "targeting_match_targeted_release", + "set": { + "/flags/garden-layout/metadata/variant_key": "arm_a" + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.targeting_match_targeted_release_forbidden_holdout_id", + "template": "targeting_match_targeted_release", + "set": { + "/flags/garden-layout/metadata/holdout_id": 401 + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.targeting_match_targeted_release_wrong_condition_index", + "template": "targeting_match_targeted_release", + "set": { + "/flags/garden-layout/reason/condition_index": null + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "type", + "instance_path": "/flags/garden-layout/reason/condition_index" + } + }, + { + "id": "responses.targeting_match_targeted_release_forbidden_failed", + "template": "targeting_match_targeted_release", + "set": { + "/flags/garden-layout/failed": true, + "/flags/garden-layout/value": null, + "/errorsWhileComputingFlags": true + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.targeting_match_targeted_release_flag_with_experiment", + "template": "targeting_match_targeted_release", + "set": { + "/flags/garden-layout/metadata/has_experiment": true + }, + "expected": "valid" + }, + { + "id": "responses.targeting_match_percentage_rollout", + "template": "targeting_match_percentage_rollout", + "expected": "valid" + }, + { + "id": "responses.targeting_match_percentage_rollout_missing_has_experiment", + "template": "targeting_match_percentage_rollout", + "remove": [ + "/flags/garden-layout/metadata/has_experiment" + ], + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "has_experiment" + } + }, + { + "id": "responses.targeting_match_percentage_rollout_missing_rule_type", + "template": "targeting_match_percentage_rollout", + "remove": [ + "/flags/garden-layout/metadata/rule_type" + ], + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "rule_type" + } + }, + { + "id": "responses.targeting_match_percentage_rollout_missing_rule_id", + "template": "targeting_match_percentage_rollout", + "remove": [ + "/flags/garden-layout/metadata/rule_id" + ], + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "rule_id" + } + }, + { + "id": "responses.targeting_match_percentage_rollout_forbidden_experiment_id", + "template": "targeting_match_percentage_rollout", + "set": { + "/flags/garden-layout/metadata/experiment_id": 301 + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.targeting_match_percentage_rollout_forbidden_variant_key", + "template": "targeting_match_percentage_rollout", + "set": { + "/flags/garden-layout/metadata/variant_key": "arm_a" + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.targeting_match_percentage_rollout_forbidden_holdout_id", + "template": "targeting_match_percentage_rollout", + "set": { + "/flags/garden-layout/metadata/holdout_id": 401 + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.targeting_match_percentage_rollout_wrong_condition_index", + "template": "targeting_match_percentage_rollout", + "set": { + "/flags/garden-layout/reason/condition_index": null + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "type", + "instance_path": "/flags/garden-layout/reason/condition_index" + } + }, + { + "id": "responses.targeting_match_percentage_rollout_forbidden_failed", + "template": "targeting_match_percentage_rollout", + "set": { + "/flags/garden-layout/failed": true, + "/flags/garden-layout/value": null, + "/errorsWhileComputingFlags": true + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.experiment_split", + "template": "experiment_split", + "expected": "valid" + }, + { + "id": "responses.experiment_split_missing_has_experiment", + "template": "experiment_split", + "remove": [ + "/flags/garden-layout/metadata/has_experiment" + ], + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "has_experiment" + } + }, + { + "id": "responses.experiment_split_missing_rule_type", + "template": "experiment_split", + "remove": [ + "/flags/garden-layout/metadata/rule_type" + ], + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "rule_type" + } + }, + { + "id": "responses.experiment_split_missing_rule_id", + "template": "experiment_split", + "remove": [ + "/flags/garden-layout/metadata/rule_id" + ], + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "rule_id" + } + }, + { + "id": "responses.experiment_split_missing_experiment_id", + "template": "experiment_split", + "remove": [ + "/flags/garden-layout/metadata/experiment_id" + ], + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "experiment_id" + } + }, + { + "id": "responses.experiment_split_missing_variant_key", + "template": "experiment_split", + "remove": [ + "/flags/garden-layout/metadata/variant_key" + ], + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "variant_key" + } + }, + { + "id": "responses.experiment_split_forbidden_holdout_id", + "template": "experiment_split", + "set": { + "/flags/garden-layout/metadata/holdout_id": 401 + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.experiment_split_wrong_condition_index", + "template": "experiment_split", + "set": { + "/flags/garden-layout/reason/condition_index": null + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "type", + "instance_path": "/flags/garden-layout/reason/condition_index" + } + }, + { + "id": "responses.experiment_split_forbidden_failed", + "template": "experiment_split", + "set": { + "/flags/garden-layout/failed": true, + "/flags/garden-layout/value": null, + "/errorsWhileComputingFlags": true + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.experiment_split_contradicted_has_experiment", + "template": "experiment_split", + "set": { + "/flags/garden-layout/metadata/has_experiment": false + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "const", + "instance_path": "/flags/garden-layout/metadata/has_experiment" + } + }, + { + "id": "responses.rollout_miss_percentage_rollout", + "template": "rollout_miss_percentage_rollout", + "expected": "valid" + }, + { + "id": "responses.rollout_miss_percentage_rollout_missing_has_experiment", + "template": "rollout_miss_percentage_rollout", + "remove": [ + "/flags/garden-layout/metadata/has_experiment" + ], + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "has_experiment" + } + }, + { + "id": "responses.rollout_miss_percentage_rollout_missing_rule_type", + "template": "rollout_miss_percentage_rollout", + "remove": [ + "/flags/garden-layout/metadata/rule_type" + ], + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "rule_type" + } + }, + { + "id": "responses.rollout_miss_percentage_rollout_missing_rule_id", + "template": "rollout_miss_percentage_rollout", + "remove": [ + "/flags/garden-layout/metadata/rule_id" + ], + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "rule_id" + } + }, + { + "id": "responses.rollout_miss_percentage_rollout_forbidden_experiment_id", + "template": "rollout_miss_percentage_rollout", + "set": { + "/flags/garden-layout/metadata/experiment_id": 301 + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.rollout_miss_percentage_rollout_forbidden_variant_key", + "template": "rollout_miss_percentage_rollout", + "set": { + "/flags/garden-layout/metadata/variant_key": "arm_a" + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.rollout_miss_percentage_rollout_forbidden_holdout_id", + "template": "rollout_miss_percentage_rollout", + "set": { + "/flags/garden-layout/metadata/holdout_id": 401 + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.rollout_miss_percentage_rollout_wrong_condition_index", + "template": "rollout_miss_percentage_rollout", + "set": { + "/flags/garden-layout/reason/condition_index": null + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "type", + "instance_path": "/flags/garden-layout/reason/condition_index" + } + }, + { + "id": "responses.rollout_miss_percentage_rollout_forbidden_failed", + "template": "rollout_miss_percentage_rollout", + "set": { + "/flags/garden-layout/failed": true, + "/flags/garden-layout/value": null, + "/errorsWhileComputingFlags": true + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.rollout_miss_experiment", + "template": "rollout_miss_experiment", + "expected": "valid" + }, + { + "id": "responses.rollout_miss_experiment_missing_has_experiment", + "template": "rollout_miss_experiment", + "remove": [ + "/flags/garden-layout/metadata/has_experiment" + ], + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "has_experiment" + } + }, + { + "id": "responses.rollout_miss_experiment_missing_rule_type", + "template": "rollout_miss_experiment", + "remove": [ + "/flags/garden-layout/metadata/rule_type" + ], + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "rule_type" + } + }, + { + "id": "responses.rollout_miss_experiment_missing_rule_id", + "template": "rollout_miss_experiment", + "remove": [ + "/flags/garden-layout/metadata/rule_id" + ], + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "rule_id" + } + }, + { + "id": "responses.rollout_miss_experiment_missing_experiment_id", + "template": "rollout_miss_experiment", + "remove": [ + "/flags/garden-layout/metadata/experiment_id" + ], + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "experiment_id" + } + }, + { + "id": "responses.rollout_miss_experiment_forbidden_variant_key", + "template": "rollout_miss_experiment", + "set": { + "/flags/garden-layout/metadata/variant_key": "arm_a" + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.rollout_miss_experiment_forbidden_holdout_id", + "template": "rollout_miss_experiment", + "set": { + "/flags/garden-layout/metadata/holdout_id": 401 + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.rollout_miss_experiment_wrong_condition_index", + "template": "rollout_miss_experiment", + "set": { + "/flags/garden-layout/reason/condition_index": null + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "type", + "instance_path": "/flags/garden-layout/reason/condition_index" + } + }, + { + "id": "responses.rollout_miss_experiment_forbidden_failed", + "template": "rollout_miss_experiment", + "set": { + "/flags/garden-layout/failed": true, + "/flags/garden-layout/value": null, + "/errorsWhileComputingFlags": true + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.experiment_paused", + "template": "experiment_paused", + "expected": "valid" + }, + { + "id": "responses.experiment_paused_missing_has_experiment", + "template": "experiment_paused", + "remove": [ + "/flags/garden-layout/metadata/has_experiment" + ], + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "has_experiment" + } + }, + { + "id": "responses.experiment_paused_missing_rule_type", + "template": "experiment_paused", + "remove": [ + "/flags/garden-layout/metadata/rule_type" + ], + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "rule_type" + } + }, + { + "id": "responses.experiment_paused_missing_rule_id", + "template": "experiment_paused", + "remove": [ + "/flags/garden-layout/metadata/rule_id" + ], + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "rule_id" + } + }, + { + "id": "responses.experiment_paused_missing_experiment_id", + "template": "experiment_paused", + "remove": [ + "/flags/garden-layout/metadata/experiment_id" + ], + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "experiment_id" + } + }, + { + "id": "responses.experiment_paused_forbidden_variant_key", + "template": "experiment_paused", + "set": { + "/flags/garden-layout/metadata/variant_key": "arm_a" + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.experiment_paused_forbidden_holdout_id", + "template": "experiment_paused", + "set": { + "/flags/garden-layout/metadata/holdout_id": 401 + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.experiment_paused_wrong_condition_index", + "template": "experiment_paused", + "set": { + "/flags/garden-layout/reason/condition_index": null + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "type", + "instance_path": "/flags/garden-layout/reason/condition_index" + } + }, + { + "id": "responses.experiment_paused_forbidden_failed", + "template": "experiment_paused", + "set": { + "/flags/garden-layout/failed": true, + "/flags/garden-layout/value": null, + "/errorsWhileComputingFlags": true + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.holdout", + "template": "holdout", + "expected": "valid" + }, + { + "id": "responses.holdout_missing_has_experiment", + "template": "holdout", + "remove": [ + "/flags/garden-layout/metadata/has_experiment" + ], + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "has_experiment" + } + }, + { + "id": "responses.holdout_missing_rule_type", + "template": "holdout", + "remove": [ + "/flags/garden-layout/metadata/rule_type" + ], + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "rule_type" + } + }, + { + "id": "responses.holdout_missing_rule_id", + "template": "holdout", + "remove": [ + "/flags/garden-layout/metadata/rule_id" + ], + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "rule_id" + } + }, + { + "id": "responses.holdout_missing_experiment_id", + "template": "holdout", + "remove": [ + "/flags/garden-layout/metadata/experiment_id" + ], + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "experiment_id" + } + }, + { + "id": "responses.holdout_missing_holdout_id", + "template": "holdout", + "remove": [ + "/flags/garden-layout/metadata/holdout_id" + ], + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "holdout_id" + } + }, + { + "id": "responses.holdout_forbidden_variant_key", + "template": "holdout", + "set": { + "/flags/garden-layout/metadata/variant_key": "arm_a" + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.holdout_wrong_condition_index", + "template": "holdout", + "set": { + "/flags/garden-layout/reason/condition_index": null + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "type", + "instance_path": "/flags/garden-layout/reason/condition_index" + } + }, + { + "id": "responses.holdout_forbidden_failed", + "template": "holdout", + "set": { + "/flags/garden-layout/failed": true, + "/flags/garden-layout/value": null, + "/errorsWhileComputingFlags": true + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.no_rule_match", + "template": "no_rule_match", + "expected": "valid" + }, + { + "id": "responses.no_rule_match_missing_has_experiment", + "template": "no_rule_match", + "remove": [ + "/flags/garden-layout/metadata/has_experiment" + ], + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "has_experiment" + } + }, + { + "id": "responses.no_rule_match_forbidden_rule_type", + "template": "no_rule_match", + "set": { + "/flags/garden-layout/metadata/rule_type": "experiment" + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.no_rule_match_forbidden_rule_id", + "template": "no_rule_match", + "set": { + "/flags/garden-layout/metadata/rule_id": "10000000-0000-4000-8000-000000000001" + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.no_rule_match_forbidden_experiment_id", + "template": "no_rule_match", + "set": { + "/flags/garden-layout/metadata/experiment_id": 301 + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.no_rule_match_forbidden_variant_key", + "template": "no_rule_match", + "set": { + "/flags/garden-layout/metadata/variant_key": "arm_a" + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.no_rule_match_forbidden_holdout_id", + "template": "no_rule_match", + "set": { + "/flags/garden-layout/metadata/holdout_id": 401 + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.no_rule_match_wrong_condition_index", + "template": "no_rule_match", + "set": { + "/flags/garden-layout/reason/condition_index": 0 + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "type", + "instance_path": "/flags/garden-layout/reason/condition_index" + } + }, + { + "id": "responses.no_rule_match_forbidden_failed", + "template": "no_rule_match", + "set": { + "/flags/garden-layout/failed": true, + "/flags/garden-layout/value": null, + "/errorsWhileComputingFlags": true + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "not", + "instance_path": "/flags/garden-layout" + } + }, + { + "id": "responses.missing_group_key", + "template": "missing_group_key", + "expected": "valid" + }, + { + "id": "responses.missing_group_key_missing_has_experiment", + "template": "missing_group_key", + "remove": [ + "/flags/garden-layout/metadata/has_experiment" + ], + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "has_experiment" + } + }, + { + "id": "responses.missing_group_key_forbidden_rule_type", + "template": "missing_group_key", + "set": { + "/flags/garden-layout/metadata/rule_type": "experiment" + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.missing_group_key_forbidden_rule_id", + "template": "missing_group_key", + "set": { + "/flags/garden-layout/metadata/rule_id": "10000000-0000-4000-8000-000000000001" + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.missing_group_key_forbidden_experiment_id", + "template": "missing_group_key", + "set": { + "/flags/garden-layout/metadata/experiment_id": 301 + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.missing_group_key_forbidden_variant_key", + "template": "missing_group_key", + "set": { + "/flags/garden-layout/metadata/variant_key": "arm_a" + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.missing_group_key_forbidden_holdout_id", + "template": "missing_group_key", + "set": { + "/flags/garden-layout/metadata/holdout_id": 401 + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.missing_group_key_wrong_condition_index", + "template": "missing_group_key", + "set": { + "/flags/garden-layout/reason/condition_index": 0 + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "type", + "instance_path": "/flags/garden-layout/reason/condition_index" + } + }, + { + "id": "responses.missing_group_key_forbidden_failed", + "template": "missing_group_key", + "set": { + "/flags/garden-layout/failed": true, + "/flags/garden-layout/value": null, + "/errorsWhileComputingFlags": true + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "not", + "instance_path": "/flags/garden-layout" + } + }, + { + "id": "responses.dependency_error", + "template": "dependency_error", + "expected": "valid" + }, + { + "id": "responses.dependency_error_missing_has_experiment", + "template": "dependency_error", + "remove": [ + "/flags/garden-layout/metadata/has_experiment" + ], + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "has_experiment" + } + }, + { + "id": "responses.dependency_error_forbidden_rule_type", + "template": "dependency_error", + "set": { + "/flags/garden-layout/metadata/rule_type": "experiment" + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.dependency_error_forbidden_rule_id", + "template": "dependency_error", + "set": { + "/flags/garden-layout/metadata/rule_id": "10000000-0000-4000-8000-000000000001" + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.dependency_error_forbidden_experiment_id", + "template": "dependency_error", + "set": { + "/flags/garden-layout/metadata/experiment_id": 301 + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.dependency_error_forbidden_variant_key", + "template": "dependency_error", + "set": { + "/flags/garden-layout/metadata/variant_key": "arm_a" + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.dependency_error_forbidden_holdout_id", + "template": "dependency_error", + "set": { + "/flags/garden-layout/metadata/holdout_id": 401 + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.dependency_error_wrong_condition_index", + "template": "dependency_error", + "set": { + "/flags/garden-layout/reason/condition_index": 0 + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "type", + "instance_path": "/flags/garden-layout/reason/condition_index" + } + }, + { + "id": "responses.dependency_error_missing_failed", + "template": "dependency_error", + "remove": [ + "/flags/garden-layout/failed" + ], + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "required", + "instance_path": "/flags/garden-layout", + "message_contains": "failed" + } + }, + { + "id": "responses.dependency_error_nonnull_value", + "template": "dependency_error", + "set": { + "/flags/garden-layout/value": false + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "type", + "instance_path": "/flags/garden-layout/value" + } + }, + { + "id": "responses.error", + "template": "error", + "expected": "valid" + }, + { + "id": "responses.error_missing_has_experiment", + "template": "error", + "remove": [ + "/flags/garden-layout/metadata/has_experiment" + ], + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "has_experiment" + } + }, + { + "id": "responses.error_forbidden_rule_type", + "template": "error", + "set": { + "/flags/garden-layout/metadata/rule_type": "experiment" + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.error_forbidden_rule_id", + "template": "error", + "set": { + "/flags/garden-layout/metadata/rule_id": "10000000-0000-4000-8000-000000000001" + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.error_forbidden_experiment_id", + "template": "error", + "set": { + "/flags/garden-layout/metadata/experiment_id": 301 + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.error_forbidden_variant_key", + "template": "error", + "set": { + "/flags/garden-layout/metadata/variant_key": "arm_a" + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.error_forbidden_holdout_id", + "template": "error", + "set": { + "/flags/garden-layout/metadata/holdout_id": 401 + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.error_wrong_condition_index", + "template": "error", + "set": { + "/flags/garden-layout/reason/condition_index": 0 + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "type", + "instance_path": "/flags/garden-layout/reason/condition_index" + } + }, + { + "id": "responses.error_missing_failed", + "template": "error", + "remove": [ + "/flags/garden-layout/failed" + ], + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "required", + "instance_path": "/flags/garden-layout", + "message_contains": "failed" + } + }, + { + "id": "responses.error_nonnull_value", + "template": "error", + "set": { + "/flags/garden-layout/value": false + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "type", + "instance_path": "/flags/garden-layout/value" + } + }, + { + "id": "responses.v1", + "template": "v1", + "expected": "valid" + }, + { + "id": "responses.omitted", + "template": "omitted", + "expected": "valid" + }, + { + "id": "responses.split_wrong_type_config_version", + "template": "experiment_split", + "set": { + "/flags/garden-layout/metadata/config_version": "2" + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "enum", + "instance_path": "/flags/garden-layout/metadata/config_version" + } + }, + { + "id": "responses.split_wrong_type_rule_type", + "template": "experiment_split", + "set": { + "/flags/garden-layout/metadata/rule_type": 2 + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "enum", + "instance_path": "/flags/garden-layout/metadata/rule_type" + } + }, + { + "id": "responses.split_wrong_type_rule_id", + "template": "experiment_split", + "set": { + "/flags/garden-layout/metadata/rule_id": 3 + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "type", + "instance_path": "/flags/garden-layout/metadata/rule_id" + } + }, + { + "id": "responses.split_wrong_type_experiment_id", + "template": "experiment_split", + "set": { + "/flags/garden-layout/metadata/experiment_id": "301" + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "type", + "instance_path": "/flags/garden-layout/metadata/experiment_id" + } + }, + { + "id": "responses.split_wrong_type_variant_key", + "template": "experiment_split", + "set": { + "/flags/garden-layout/metadata/variant_key": true + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "type", + "instance_path": "/flags/garden-layout/metadata/variant_key" + } + }, + { + "id": "responses.forbidden_legacy_enabled", + "template": "experiment_split", + "set": { + "/flags/garden-layout/enabled": true + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "additionalProperties", + "instance_path": "/flags/garden-layout", + "message_contains": "enabled" + } + }, + { + "id": "responses.forbidden_legacy_variant", + "template": "experiment_split", + "set": { + "/flags/garden-layout/variant": "arm_a" + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "additionalProperties", + "instance_path": "/flags/garden-layout", + "message_contains": "variant" + } + }, + { + "id": "responses.split_arm_b_equal_value", + "template": "experiment_split", + "set": { + "/flags/garden-layout/metadata/variant_key": "arm_b" + }, + "expected": "valid" + }, + { + "id": "responses.split_string_value", + "template": "experiment_split", + "set": { + "/flags/garden-layout/value": "same-copy" + }, + "expected": "valid" + }, + { + "id": "responses.null_default", + "template": "no_rule_match", + "set": { + "/flags/garden-layout/value": null + }, + "expected": "valid" + }, + { + "id": "responses.request_failure_omission", + "template": "omitted", + "set": { + "/errorsWhileComputingFlags": true + }, + "expected": "valid" + }, + { + "id": "responses.missing_group_nonnull_value", + "template": "missing_group_key", + "set": { + "/flags/garden-layout/value": false + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "type", + "instance_path": "/flags/garden-layout/value" + } + }, + { + "id": "responses.v1_failed", + "template": "v1", + "set": { + "/flags/garden-layout/failed": true, + "/flags/garden-layout/value": null, + "/flags/garden-layout/reason": { + "code": "missing_dependency", + "condition_index": null + }, + "/errorsWhileComputingFlags": true + }, + "remove": [ + "/flags/garden-layout/metadata/variant_key" + ], + "expected": "valid" + }, + { + "id": "responses.v1_quick_win", + "template": "v1", + "set": { + "/flags/garden-layout/metadata/experiment_id": 301, + "/flags/garden-layout/metadata/forced_variant": true, + "/flags/garden-layout/metadata/has_experiment": true + }, + "expected": "valid" + }, + { + "id": "responses.v2_forced_variant", + "template": "experiment_split", + "set": { + "/flags/garden-layout/metadata/forced_variant": true + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.key_mismatch", + "template": "v1", + "set": { + "/flags/garden-layout/key": "different-key" + }, + "expected": "invalid", + "expected_failure": { + "layer": "semantic", + "keyword": "map_key", + "instance_path": "/flags/garden-layout/key" + } + }, + { + "id": "responses.failure_envelope", + "template": "error", + "set": { + "/errorsWhileComputingFlags": false + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "const", + "instance_path": "/errorsWhileComputingFlags" + } + }, + { + "id": "responses.local_only_reason", + "template": "no_rule_match", + "set": { + "/flags/garden-layout/reason/code": "flag_disabled" + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "enum", + "instance_path": "/flags/garden-layout/reason/code" + } + }, + { + "id": "responses.nested_seed", + "template": "no_rule_match", + "set": { + "/flags/garden-layout/conditions": [ + { + "nested": [ + { + "seed": "invented-assignment" + } + ] + } + ] + }, + "expected": "invalid", + "expected_failure": { + "layer": "seed", + "keyword": "assignment_seed", + "instance_path": "/flags/garden-layout/conditions/0/nested/0/seed" + } + }, + { + "id": "responses.passthrough_seed", + "template": "omitted", + "set": { + "/future": { + "items": [ + { + "holdout_seed": "invented-holdout" + } + ] + } + }, + "expected": "invalid", + "expected_failure": { + "layer": "seed", + "keyword": "assignment_seed", + "instance_path": "/future/items/0/holdout_seed" + } + }, + { + "id": "responses.reserved_number_wire_value", + "template": "no_rule_match", + "set": { + "/flags/garden-layout/value": 7 + }, + "expected": "valid" + }, + { + "id": "responses.reserved_object_wire_value", + "template": "no_rule_match", + "set": { + "/flags/garden-layout/value": { + "layout": "compact" + } + }, + "expected": "valid" + }, + { + "id": "responses.targeting_match_targeted_release_wrong_rule_type", + "template": "targeting_match_targeted_release", + "set": { + "/flags/garden-layout/metadata/rule_type": "experiment" + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "const", + "instance_path": "/flags/garden-layout/metadata/rule_type" + } + }, + { + "id": "responses.experiment_split_wrong_rule_type", + "template": "experiment_split", + "set": { + "/flags/garden-layout/metadata/rule_type": "percentage_rollout" + }, + "expected": "invalid", + "expected_failure": { + "layer": "schema", + "keyword": "const", + "instance_path": "/flags/garden-layout/metadata/rule_type" + } + }, + { + "id": "responses.rollout_miss_percentage_rollout_wrong_rule_type", + "template": "rollout_miss_percentage_rollout", + "set": { + "/flags/garden-layout/metadata/rule_type": "experiment" + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "const", + "instance_path": "/flags/garden-layout/metadata/rule_type" + } + }, + { + "id": "responses.rollout_miss_experiment_wrong_rule_type", + "template": "rollout_miss_experiment", + "set": { + "/flags/garden-layout/metadata/rule_type": "percentage_rollout" + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "const", + "instance_path": "/flags/garden-layout/metadata/rule_type" + } + }, + { + "id": "responses.experiment_paused_wrong_rule_type", + "template": "experiment_paused", + "set": { + "/flags/garden-layout/metadata/rule_type": "percentage_rollout" + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "const", + "instance_path": "/flags/garden-layout/metadata/rule_type" + } + }, + { + "id": "responses.holdout_wrong_rule_type", + "template": "holdout", + "set": { + "/flags/garden-layout/metadata/rule_type": "percentage_rollout" + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "const", + "instance_path": "/flags/garden-layout/metadata/rule_type" + } + }, + { + "id": "responses.targeting_match_targeted_release_null_value", + "template": "targeting_match_targeted_release", + "set": { + "/flags/garden-layout/value": null + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "type", + "instance_path": "/flags/garden-layout/value" + } + }, + { + "id": "responses.targeting_match_percentage_rollout_null_value", + "template": "targeting_match_percentage_rollout", + "set": { + "/flags/garden-layout/value": null + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "type", + "instance_path": "/flags/garden-layout/value" + } + }, + { + "id": "responses.experiment_split_null_value", + "template": "experiment_split", + "set": { + "/flags/garden-layout/value": null + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "type", + "instance_path": "/flags/garden-layout/value" + } + }, + { + "id": "responses.empty_v2_string", + "template": "no_rule_match", + "set": { + "/flags/garden-layout/value": "" + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "minLength", + "instance_path": "/flags/garden-layout/value" + } + }, + { + "id": "responses.v1_experiment_without_linkage", + "template": "v1", + "set": { + "/flags/garden-layout/metadata/experiment_id": 301 + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "const", + "instance_path": "/flags/garden-layout/metadata/has_experiment" + } + }, + { + "id": "responses.v1_ambiguous_experiment", + "template": "v1", + "set": { + "/flags/garden-layout/metadata/has_experiment": true + }, + "expected": "valid" + }, + { + "id": "responses.flag_key_seed", + "template": "omitted", + "set": { + "/flags/seed": { + "key": "seed", + "value": false, + "reason": { + "code": "experiment_split", + "condition_index": 0 + }, + "metadata": { + "id": 201, + "version": 7, + "config_version": 2, + "has_experiment": true, + "payload": null, + "rule_type": "experiment", + "rule_id": "10000000-0000-4000-8000-000000000001", + "experiment_id": 301, + "variant_key": "arm_a" + } + } + }, + "expected": "valid" + }, + { + "id": "responses.seed_named_flag_nested_seed", + "template": "omitted", + "set": { + "/flags/seed": { + "key": "seed", + "value": false, + "reason": { + "code": "experiment_split", + "condition_index": 0 + }, + "metadata": { + "id": 201, + "version": 7, + "config_version": 2, + "has_experiment": true, + "payload": null, + "rule_type": "experiment", + "rule_id": "10000000-0000-4000-8000-000000000001", + "experiment_id": 301, + "variant_key": "arm_a" + }, + "conditions": [ + { + "nested": [ + { + "seed": "invented-assignment" + } + ] + } + ] + } + }, + "expected": "invalid", + "expected_failure": { + "layer": "seed", + "keyword": "assignment_seed", + "instance_path": "/flags/seed/conditions/0/nested/0/seed" + } + }, + { + "id": "responses.flag_key_assignment_seed", + "template": "omitted", + "set": { + "/flags/assignment_seed": { + "key": "assignment_seed", + "value": false, + "reason": { + "code": "experiment_split", + "condition_index": 0 + }, + "metadata": { + "id": 201, + "version": 7, + "config_version": 2, + "has_experiment": true, + "payload": null, + "rule_type": "experiment", + "rule_id": "10000000-0000-4000-8000-000000000001", + "experiment_id": 301, + "variant_key": "arm_a" + } + } + }, + "expected": "valid" + }, + { + "id": "responses.v1_forbidden_rule_id", + "template": "v1", + "set": { + "/flags/garden-layout/metadata/rule_id": "10000000-0000-4000-8000-000000000001" + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.v1_forbidden_rule_type", + "template": "v1", + "set": { + "/flags/garden-layout/metadata/rule_type": "targeted_release" + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.v1_forbidden_holdout_id", + "template": "v1", + "set": { + "/flags/garden-layout/metadata/holdout_id": 401 + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, + { + "id": "responses.v1_object_payload", + "template": "v1", + "set": { + "/flags/garden-layout/metadata/payload": { + "columns": 2 + } + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "type", + "instance_path": "/flags/garden-layout/metadata/payload" + } + }, + { + "id": "responses.v1_null_value_not_failed", + "template": "v1", + "set": { + "/flags/garden-layout/value": null + }, + "expected": "invalid", + "expected_failure": { + "layer": "presence", + "keyword": "type", + "instance_path": "/flags/garden-layout/value" + } + } + ] +} diff --git a/posthog/test/fixtures/feature_flag_rules_v2/v2_value_evaluation.json b/posthog/test/fixtures/feature_flag_rules_v2/v2_value_evaluation.json new file mode 100644 index 000000000..c48e40782 --- /dev/null +++ b/posthog/test/fixtures/feature_flag_rules_v2/v2_value_evaluation.json @@ -0,0 +1,3080 @@ +{ + "corpus_version": "1.0.0", + "config_version": 2, + "description": "Person string, number and object ordered evaluation with the legacy rendering of each result. Expected terminal fields and legacy cells are exhaustive. Parser rows run at the parser boundary.", + "cases": [ + { + "id": "v2_value.string.target", + "family": "ordering", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "string", + "default_value": "standard", + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": "compact" + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": "compact", + "reason": "targeting_match", + "rule": { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "index": 0 + } + }, + "legacy": { + "enabled": true, + "variant": "compact", + "value": "compact", + "payload": null + } + }, + { + "id": "v2_value.string.rollout_hit", + "family": "ordering", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "string", + "default_value": "standard", + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "percentage_rollout", + "targeting": { + "properties": [] + }, + "value": "compact", + "rollout_percentage": 100, + "on_rollout_miss": "continue", + "assignment_algorithm": "sha1_60_v1", + "seed": "example-allocation", + "assign_by": "person" + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": "compact", + "reason": "targeting_match", + "rule": { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "percentage_rollout", + "index": 0 + } + }, + "legacy": { + "enabled": true, + "variant": "compact", + "value": "compact", + "payload": null + } + }, + { + "id": "v2_value.string.miss_return_default", + "family": "ordering", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "string", + "default_value": "standard", + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "percentage_rollout", + "targeting": { + "properties": [] + }, + "value": "compact", + "rollout_percentage": 0, + "on_rollout_miss": "return_default", + "assignment_algorithm": "sha1_60_v1", + "seed": "example-allocation", + "assign_by": "person" + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": "wide" + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": "standard", + "reason": "rollout_miss", + "rule": { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "percentage_rollout", + "index": 0 + } + }, + "legacy": { + "enabled": true, + "variant": "standard", + "value": "standard", + "payload": null + } + }, + { + "id": "v2_value.string.miss_return_default_null", + "family": "ordering", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "string", + "default_value": null, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "percentage_rollout", + "targeting": { + "properties": [] + }, + "value": "compact", + "rollout_percentage": 0, + "on_rollout_miss": "return_default", + "assignment_algorithm": "sha1_60_v1", + "seed": "example-allocation", + "assign_by": "person" + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": "wide" + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": null, + "reason": "rollout_miss", + "rule": { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "percentage_rollout", + "index": 0 + } + }, + "legacy": { + "enabled": false, + "variant": null, + "value": false, + "payload": null + } + }, + { + "id": "v2_value.string.miss_continue", + "family": "ordering", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "string", + "default_value": "standard", + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "percentage_rollout", + "targeting": { + "properties": [] + }, + "value": "compact", + "rollout_percentage": 0, + "on_rollout_miss": "continue", + "assignment_algorithm": "sha1_60_v1", + "seed": "example-allocation", + "assign_by": "person" + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": "wide" + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": "wide", + "reason": "targeting_match", + "rule": { + "id": "22222222-2222-4222-8222-222222222222", + "rule_type": "targeted_release", + "index": 1 + } + }, + "legacy": { + "enabled": true, + "variant": "wide", + "value": "wide", + "payload": null + } + }, + { + "id": "v2_value.string.no_match_default", + "family": "ordering", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "string", + "default_value": "standard", + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [ + { + "key": "plan", + "type": "person", + "operator": "exact", + "value": [ + "free" + ], + "negation": false + } + ] + }, + "value": "compact" + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": "standard", + "reason": "no_rule_match" + }, + "legacy": { + "enabled": true, + "variant": "standard", + "value": "standard", + "payload": null + } + }, + { + "id": "v2_value.string.no_match_null", + "family": "ordering", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "string", + "default_value": null, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [ + { + "key": "plan", + "type": "person", + "operator": "exact", + "value": [ + "free" + ], + "negation": false + } + ] + }, + "value": "compact" + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": null, + "reason": "no_rule_match" + }, + "legacy": { + "enabled": false, + "variant": null, + "value": false, + "payload": null + } + }, + { + "id": "v2_value.string.order_first_match", + "family": "ordering", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "string", + "default_value": "standard", + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [ + { + "key": "plan", + "type": "person", + "operator": "exact", + "value": [ + "pro" + ], + "negation": false + } + ] + }, + "value": "compact" + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": "wide" + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": "compact", + "reason": "targeting_match", + "rule": { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "index": 0 + } + }, + "legacy": { + "enabled": true, + "variant": "compact", + "value": "compact", + "payload": null + } + }, + { + "id": "v2_value.string.order_targeting_miss", + "family": "ordering", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "string", + "default_value": "standard", + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [ + { + "key": "plan", + "type": "person", + "operator": "exact", + "value": [ + "free" + ], + "negation": false + } + ] + }, + "value": "compact" + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "rule_type": "targeted_release", + "targeting": { + "properties": [ + { + "key": "plan", + "type": "person", + "operator": "exact", + "value": [ + "pro" + ], + "negation": false + } + ] + }, + "value": "wide" + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": "wide", + "reason": "targeting_match", + "rule": { + "id": "22222222-2222-4222-8222-222222222222", + "rule_type": "targeted_release", + "index": 1 + } + }, + "legacy": { + "enabled": true, + "variant": "wide", + "value": "wide", + "payload": null + } + }, + { + "id": "v2_value.number.target", + "family": "ordering", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "number", + "default_value": 0, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": 1.25 + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": 1.25, + "reason": "targeting_match", + "rule": { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "index": 0 + } + }, + "legacy": { + "enabled": true, + "variant": null, + "value": true, + "payload": 1.25 + } + }, + { + "id": "v2_value.number.rollout_hit", + "family": "ordering", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "number", + "default_value": 0, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "percentage_rollout", + "targeting": { + "properties": [] + }, + "value": 1.25, + "rollout_percentage": 100, + "on_rollout_miss": "continue", + "assignment_algorithm": "sha1_60_v1", + "seed": "example-allocation", + "assign_by": "person" + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": 1.25, + "reason": "targeting_match", + "rule": { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "percentage_rollout", + "index": 0 + } + }, + "legacy": { + "enabled": true, + "variant": null, + "value": true, + "payload": 1.25 + } + }, + { + "id": "v2_value.number.miss_return_default", + "family": "ordering", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "number", + "default_value": 0, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "percentage_rollout", + "targeting": { + "properties": [] + }, + "value": 1.25, + "rollout_percentage": 0, + "on_rollout_miss": "return_default", + "assignment_algorithm": "sha1_60_v1", + "seed": "example-allocation", + "assign_by": "person" + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": -40 + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": 0, + "reason": "rollout_miss", + "rule": { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "percentage_rollout", + "index": 0 + } + }, + "legacy": { + "enabled": true, + "variant": null, + "value": true, + "payload": 0 + } + }, + { + "id": "v2_value.number.miss_return_default_null", + "family": "ordering", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "number", + "default_value": null, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "percentage_rollout", + "targeting": { + "properties": [] + }, + "value": 1.25, + "rollout_percentage": 0, + "on_rollout_miss": "return_default", + "assignment_algorithm": "sha1_60_v1", + "seed": "example-allocation", + "assign_by": "person" + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": -40 + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": null, + "reason": "rollout_miss", + "rule": { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "percentage_rollout", + "index": 0 + } + }, + "legacy": { + "enabled": false, + "variant": null, + "value": false, + "payload": null + } + }, + { + "id": "v2_value.number.miss_continue", + "family": "ordering", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "number", + "default_value": 0, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "percentage_rollout", + "targeting": { + "properties": [] + }, + "value": 1.25, + "rollout_percentage": 0, + "on_rollout_miss": "continue", + "assignment_algorithm": "sha1_60_v1", + "seed": "example-allocation", + "assign_by": "person" + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": -40 + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": -40, + "reason": "targeting_match", + "rule": { + "id": "22222222-2222-4222-8222-222222222222", + "rule_type": "targeted_release", + "index": 1 + } + }, + "legacy": { + "enabled": true, + "variant": null, + "value": true, + "payload": -40 + } + }, + { + "id": "v2_value.number.no_match_default", + "family": "ordering", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "number", + "default_value": 0, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [ + { + "key": "plan", + "type": "person", + "operator": "exact", + "value": [ + "free" + ], + "negation": false + } + ] + }, + "value": 1.25 + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": 0, + "reason": "no_rule_match" + }, + "legacy": { + "enabled": true, + "variant": null, + "value": true, + "payload": 0 + } + }, + { + "id": "v2_value.number.no_match_null", + "family": "ordering", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "number", + "default_value": null, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [ + { + "key": "plan", + "type": "person", + "operator": "exact", + "value": [ + "free" + ], + "negation": false + } + ] + }, + "value": 1.25 + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": null, + "reason": "no_rule_match" + }, + "legacy": { + "enabled": false, + "variant": null, + "value": false, + "payload": null + } + }, + { + "id": "v2_value.number.order_first_match", + "family": "ordering", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "number", + "default_value": 0, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [ + { + "key": "plan", + "type": "person", + "operator": "exact", + "value": [ + "pro" + ], + "negation": false + } + ] + }, + "value": 1.25 + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": -40 + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": 1.25, + "reason": "targeting_match", + "rule": { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "index": 0 + } + }, + "legacy": { + "enabled": true, + "variant": null, + "value": true, + "payload": 1.25 + } + }, + { + "id": "v2_value.number.order_targeting_miss", + "family": "ordering", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "number", + "default_value": 0, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [ + { + "key": "plan", + "type": "person", + "operator": "exact", + "value": [ + "free" + ], + "negation": false + } + ] + }, + "value": 1.25 + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "rule_type": "targeted_release", + "targeting": { + "properties": [ + { + "key": "plan", + "type": "person", + "operator": "exact", + "value": [ + "pro" + ], + "negation": false + } + ] + }, + "value": -40 + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": -40, + "reason": "targeting_match", + "rule": { + "id": "22222222-2222-4222-8222-222222222222", + "rule_type": "targeted_release", + "index": 1 + } + }, + "legacy": { + "enabled": true, + "variant": null, + "value": true, + "payload": -40 + } + }, + { + "id": "v2_value.object.target", + "family": "ordering", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "object", + "default_value": { + "layout": "standard" + }, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": { + "layout": "compact", + "options": [ + 1, + true, + null + ] + } + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": { + "layout": "compact", + "options": [ + 1, + true, + null + ] + }, + "reason": "targeting_match", + "rule": { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "index": 0 + } + }, + "legacy": { + "enabled": true, + "variant": null, + "value": true, + "payload": { + "layout": "compact", + "options": [ + 1, + true, + null + ] + } + } + }, + { + "id": "v2_value.object.rollout_hit", + "family": "ordering", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "object", + "default_value": { + "layout": "standard" + }, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "percentage_rollout", + "targeting": { + "properties": [] + }, + "value": { + "layout": "compact", + "options": [ + 1, + true, + null + ] + }, + "rollout_percentage": 100, + "on_rollout_miss": "continue", + "assignment_algorithm": "sha1_60_v1", + "seed": "example-allocation", + "assign_by": "person" + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": { + "layout": "compact", + "options": [ + 1, + true, + null + ] + }, + "reason": "targeting_match", + "rule": { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "percentage_rollout", + "index": 0 + } + }, + "legacy": { + "enabled": true, + "variant": null, + "value": true, + "payload": { + "layout": "compact", + "options": [ + 1, + true, + null + ] + } + } + }, + { + "id": "v2_value.object.miss_return_default", + "family": "ordering", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "object", + "default_value": { + "layout": "standard" + }, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "percentage_rollout", + "targeting": { + "properties": [] + }, + "value": { + "layout": "compact", + "options": [ + 1, + true, + null + ] + }, + "rollout_percentage": 0, + "on_rollout_miss": "return_default", + "assignment_algorithm": "sha1_60_v1", + "seed": "example-allocation", + "assign_by": "person" + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": { + "layout": "wide" + } + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": { + "layout": "standard" + }, + "reason": "rollout_miss", + "rule": { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "percentage_rollout", + "index": 0 + } + }, + "legacy": { + "enabled": true, + "variant": null, + "value": true, + "payload": { + "layout": "standard" + } + } + }, + { + "id": "v2_value.object.miss_return_default_null", + "family": "ordering", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "object", + "default_value": null, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "percentage_rollout", + "targeting": { + "properties": [] + }, + "value": { + "layout": "compact", + "options": [ + 1, + true, + null + ] + }, + "rollout_percentage": 0, + "on_rollout_miss": "return_default", + "assignment_algorithm": "sha1_60_v1", + "seed": "example-allocation", + "assign_by": "person" + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": { + "layout": "wide" + } + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": null, + "reason": "rollout_miss", + "rule": { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "percentage_rollout", + "index": 0 + } + }, + "legacy": { + "enabled": false, + "variant": null, + "value": false, + "payload": null + } + }, + { + "id": "v2_value.object.miss_continue", + "family": "ordering", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "object", + "default_value": { + "layout": "standard" + }, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "percentage_rollout", + "targeting": { + "properties": [] + }, + "value": { + "layout": "compact", + "options": [ + 1, + true, + null + ] + }, + "rollout_percentage": 0, + "on_rollout_miss": "continue", + "assignment_algorithm": "sha1_60_v1", + "seed": "example-allocation", + "assign_by": "person" + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": { + "layout": "wide" + } + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": { + "layout": "wide" + }, + "reason": "targeting_match", + "rule": { + "id": "22222222-2222-4222-8222-222222222222", + "rule_type": "targeted_release", + "index": 1 + } + }, + "legacy": { + "enabled": true, + "variant": null, + "value": true, + "payload": { + "layout": "wide" + } + } + }, + { + "id": "v2_value.object.no_match_default", + "family": "ordering", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "object", + "default_value": { + "layout": "standard" + }, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [ + { + "key": "plan", + "type": "person", + "operator": "exact", + "value": [ + "free" + ], + "negation": false + } + ] + }, + "value": { + "layout": "compact", + "options": [ + 1, + true, + null + ] + } + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": { + "layout": "standard" + }, + "reason": "no_rule_match" + }, + "legacy": { + "enabled": true, + "variant": null, + "value": true, + "payload": { + "layout": "standard" + } + } + }, + { + "id": "v2_value.object.no_match_null", + "family": "ordering", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "object", + "default_value": null, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [ + { + "key": "plan", + "type": "person", + "operator": "exact", + "value": [ + "free" + ], + "negation": false + } + ] + }, + "value": { + "layout": "compact", + "options": [ + 1, + true, + null + ] + } + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": null, + "reason": "no_rule_match" + }, + "legacy": { + "enabled": false, + "variant": null, + "value": false, + "payload": null + } + }, + { + "id": "v2_value.object.order_first_match", + "family": "ordering", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "object", + "default_value": { + "layout": "standard" + }, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [ + { + "key": "plan", + "type": "person", + "operator": "exact", + "value": [ + "pro" + ], + "negation": false + } + ] + }, + "value": { + "layout": "compact", + "options": [ + 1, + true, + null + ] + } + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": { + "layout": "wide" + } + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": { + "layout": "compact", + "options": [ + 1, + true, + null + ] + }, + "reason": "targeting_match", + "rule": { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "index": 0 + } + }, + "legacy": { + "enabled": true, + "variant": null, + "value": true, + "payload": { + "layout": "compact", + "options": [ + 1, + true, + null + ] + } + } + }, + { + "id": "v2_value.object.order_targeting_miss", + "family": "ordering", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "object", + "default_value": { + "layout": "standard" + }, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [ + { + "key": "plan", + "type": "person", + "operator": "exact", + "value": [ + "free" + ], + "negation": false + } + ] + }, + "value": { + "layout": "compact", + "options": [ + 1, + true, + null + ] + } + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "rule_type": "targeted_release", + "targeting": { + "properties": [ + { + "key": "plan", + "type": "person", + "operator": "exact", + "value": [ + "pro" + ], + "negation": false + } + ] + }, + "value": { + "layout": "wide" + } + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": { + "layout": "wide" + }, + "reason": "targeting_match", + "rule": { + "id": "22222222-2222-4222-8222-222222222222", + "rule_type": "targeted_release", + "index": 1 + } + }, + "legacy": { + "enabled": true, + "variant": null, + "value": true, + "payload": { + "layout": "wide" + } + } + }, + { + "id": "v2_value.string.unicode", + "family": "values", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "string", + "default_value": null, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": "Grüße 👋" + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": "Grüße 👋", + "reason": "targeting_match", + "rule": { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "index": 0 + } + }, + "legacy": { + "enabled": true, + "variant": "Grüße 👋", + "value": "Grüße 👋", + "payload": null + } + }, + { + "id": "v2_value.string.looks_boolean", + "family": "values", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "string", + "default_value": null, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": "false" + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": "false", + "reason": "targeting_match", + "rule": { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "index": 0 + } + }, + "legacy": { + "enabled": true, + "variant": "false", + "value": "false", + "payload": null + } + }, + { + "id": "v2_value.number.zero", + "family": "values", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "number", + "default_value": null, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": 0 + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": 0, + "reason": "targeting_match", + "rule": { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "index": 0 + } + }, + "legacy": { + "enabled": true, + "variant": null, + "value": true, + "payload": 0 + } + }, + { + "id": "v2_value.number.negative_decimal", + "family": "values", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "number", + "default_value": null, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": -2.5 + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": -2.5, + "reason": "targeting_match", + "rule": { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "index": 0 + } + }, + "legacy": { + "enabled": true, + "variant": null, + "value": true, + "payload": -2.5 + } + }, + { + "id": "v2_value.number.short_decimal", + "family": "values", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "number", + "default_value": null, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": 0.1 + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": 0.1, + "reason": "targeting_match", + "rule": { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "index": 0 + } + }, + "legacy": { + "enabled": true, + "variant": null, + "value": true, + "payload": 0.1 + } + }, + { + "id": "v2_value.number.exponent", + "family": "values", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "number", + "default_value": null, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": 1.5e-07 + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": 1.5e-07, + "reason": "targeting_match", + "rule": { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "index": 0 + } + }, + "legacy": { + "enabled": true, + "variant": null, + "value": true, + "payload": 1.5e-07 + } + }, + { + "id": "v2_value.number.max_safe_integer", + "family": "values", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "number", + "default_value": null, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": 9007199254740991 + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": 9007199254740991, + "reason": "targeting_match", + "rule": { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "index": 0 + } + }, + "legacy": { + "enabled": true, + "variant": null, + "value": true, + "payload": 9007199254740991 + } + }, + { + "id": "v2_value.number.min_safe_integer", + "family": "values", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "number", + "default_value": null, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": -9007199254740991 + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": -9007199254740991, + "reason": "targeting_match", + "rule": { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "index": 0 + } + }, + "legacy": { + "enabled": true, + "variant": null, + "value": true, + "payload": -9007199254740991 + } + }, + { + "id": "v2_value.object.empty", + "family": "values", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "object", + "default_value": null, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": {} + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": {}, + "reason": "targeting_match", + "rule": { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "index": 0 + } + }, + "legacy": { + "enabled": true, + "variant": null, + "value": true, + "payload": {} + } + }, + { + "id": "v2_value.object.nested", + "family": "values", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "object", + "default_value": null, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": { + "theme": { + "colors": [ + "#000000", + "#ffffff" + ], + "radius": 4.5 + }, + "flags": [ + true, + false, + null + ], + "label": "Ünïcode" + } + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": { + "theme": { + "colors": [ + "#000000", + "#ffffff" + ], + "radius": 4.5 + }, + "flags": [ + true, + false, + null + ], + "label": "Ünïcode" + }, + "reason": "targeting_match", + "rule": { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "index": 0 + } + }, + "legacy": { + "enabled": true, + "variant": null, + "value": true, + "payload": { + "theme": { + "colors": [ + "#000000", + "#ffffff" + ], + "radius": 4.5 + }, + "flags": [ + true, + false, + null + ], + "label": "Ünïcode" + } + } + }, + { + "id": "v2_value.object.max_depth", + "family": "values", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "object", + "default_value": null, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "leaf": true + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "success", + "value": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "leaf": true + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + "reason": "targeting_match", + "rule": { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "index": 0 + } + }, + "legacy": { + "enabled": true, + "variant": null, + "value": true, + "payload": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "leaf": true + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + { + "id": "v2_value.parser.string_empty_value", + "family": "parser", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "string", + "default_value": "standard", + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": "" + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "parse_error", + "kind": "malformed" + } + }, + { + "id": "v2_value.parser.string_empty_default", + "family": "parser", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "string", + "default_value": "", + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": "compact" + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "parse_error", + "kind": "malformed" + } + }, + { + "id": "v2_value.parser.string_value_mismatch", + "family": "parser", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "string", + "default_value": "standard", + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": true + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "parse_error", + "kind": "malformed" + } + }, + { + "id": "v2_value.parser.number_value_mismatch", + "family": "parser", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "number", + "default_value": 0, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": "1.25" + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "parse_error", + "kind": "malformed" + } + }, + { + "id": "v2_value.parser.number_default_mismatch", + "family": "parser", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "number", + "default_value": true, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": 1.25 + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "parse_error", + "kind": "malformed" + } + }, + { + "id": "v2_value.parser.number_above_safe_integer", + "family": "parser", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "number", + "default_value": 0, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": 9007199254740992.0 + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "parse_error", + "kind": "malformed" + } + }, + { + "id": "v2_value.parser.number_below_safe_integer", + "family": "parser", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "number", + "default_value": 0, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": -9007199254740992.0 + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "parse_error", + "kind": "malformed" + } + }, + { + "id": "v2_value.parser.object_array_value", + "family": "parser", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "object", + "default_value": null, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": [ + { + "layout": "compact" + } + ] + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "parse_error", + "kind": "malformed" + } + }, + { + "id": "v2_value.parser.object_default_mismatch", + "family": "parser", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "object", + "default_value": "standard", + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": { + "layout": "compact" + } + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "parse_error", + "kind": "malformed" + } + }, + { + "id": "v2_value.parser.object_too_deep", + "family": "parser", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "object", + "default_value": null, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "child": { + "leaf": true + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "parse_error", + "kind": "malformed" + } + }, + { + "id": "v2_value.parser.object_nested_number_above_safe_integer", + "family": "parser", + "flag": { + "key": "example-display-key", + "name": "Example display name", + "active": true, + "deleted": false, + "requested": true + }, + "config": { + "version": 2, + "return_type": "object", + "default_value": null, + "rules": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "rule_type": "targeted_release", + "targeting": { + "properties": [] + }, + "value": { + "limit": 9007199254740992.0 + } + } + ] + }, + "context": { + "identifier": "person-example", + "properties": { + "plan": "pro" + }, + "properties_complete": true, + "timezone": "UTC", + "explicit_exact_matching": true, + "now": "2024-03-31T12:00:00Z" + }, + "expected": { + "status": "parse_error", + "kind": "malformed" + } + } + ] +} diff --git a/posthog/test/snapshots/flags_request.json b/posthog/test/snapshots/flags_request.json index 50a018401..2a8fbad6a 100644 --- a/posthog/test/snapshots/flags_request.json +++ b/posthog/test/snapshots/flags_request.json @@ -28,5 +28,5 @@ "User-Agent": "posthog-python/" }, "timeout": 3, - "url": "https://example.posthog.test/flags/?v=2" + "url": "https://example.posthog.test/flags/?v=3" } diff --git a/posthog/test/test_async_request.py b/posthog/test/test_async_request.py index d95da855a..062d183c0 100644 --- a/posthog/test/test_async_request.py +++ b/posthog/test/test_async_request.py @@ -242,7 +242,7 @@ async def test_async_flags_sends_v2_request_payload(): assert result == {"flags": {}} _, args, kwargs = client.calls[0] - assert args == ("/flags/?v=2",) + assert args == ("/flags/?v=3",) payload = json.loads(kwargs["content"]) assert payload["token"] == "project-key" assert "api_key" not in payload diff --git a/posthog/test/test_feature_flag.py b/posthog/test/test_feature_flag.py index 9faa56f1c..4d9d02f60 100644 --- a/posthog/test/test_feature_flag.py +++ b/posthog/test/test_feature_flag.py @@ -43,6 +43,7 @@ def test_feature_flag_from_json(self): payload='{"some": "json"}', version=2, description="test-description", + config_version=1, ), ) diff --git a/posthog/test/test_flags_v3_response.py b/posthog/test/test_flags_v3_response.py new file mode 100644 index 000000000..6990f8172 --- /dev/null +++ b/posthog/test/test_flags_v3_response.py @@ -0,0 +1,866 @@ +"""Reading the ``/flags?v=3`` response, checked against the released wire contract. + +The files in ``fixtures/feature_flag_rules_v2`` are copied unchanged from a released +``PostHog/posthog-sdk-test-harness`` contract. ``SOURCE.json`` records the release and +the digest of each file. +""" + +from __future__ import annotations + +import copy +import hashlib +import json +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional +from unittest import mock + +import pytest + +from posthog import AsyncPosthog +from posthog.client import Client +from posthog.feature_flag_evaluations import FeatureFlagEvaluations +from posthog.test.test_utils import FAKE_TEST_API_KEY +from posthog.types import ( + FeatureFlag, + FeatureFlagResult, + FlagEvaluationDetails, + FlagEvaluationErrorCode, + FlagMetadata, + normalize_flags_response, + to_flags_and_payloads, +) + +FIXTURES = Path(__file__).with_name("fixtures") / "feature_flag_rules_v2" +DISTINCT_ID = "user-1" + + +def _load(name: str) -> Any: + return json.loads((FIXTURES / name).read_text(encoding="utf-8")) + + +READERS = _load("readers.json") +RESPONSES = _load("responses.json") +LEGACY_PROJECTION = _load("legacy_projection.json") +VALUE_CORPUS = _load("v2_value_evaluation.json") + + +def _pointer(pointer: str) -> List[str]: + return [ + token.replace("~1", "/").replace("~0", "~") for token in pointer.split("/")[1:] + ] + + +def _build(fixture: Dict[str, Any], case: Dict[str, Any]) -> Dict[str, Any]: + """Apply a fixture case: copy its template, remove members, then set members.""" + document = copy.deepcopy(fixture["templates"][case["template"]]) + for pointer in case.get("remove", []): + *parents, last = _pointer(pointer) + target = document + for token in parents: + target = target[token] + del target[last] + for pointer, value in case.get("set", {}).items(): + *parents, last = _pointer(pointer) + target = document + for token in parents: + target = target[token] + target[last] = copy.deepcopy(value) + return document + + +@pytest.fixture +def client(): + client = Client(FAKE_TEST_API_KEY, send=False) + yield client + client.shutdown() + + +def _evaluate(client: Client, response: Dict[str, Any]) -> FeatureFlagEvaluations: + with mock.patch("posthog.client.flags", return_value=copy.deepcopy(response)): + 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.metadata + return ( + details.error_code is None + and metadata is not None + and details.reason is not None + and metadata.config_version == 2 + and details.reason.code == "experiment_split" + and metadata.rule_type == "experiment" + and metadata.rule_id is not None + and metadata.experiment_id is not None + and metadata.variant_key is not None + ) + + +def test_vendored_fixtures_match_the_recorded_release_digests(): + source = _load("SOURCE.json") + assert source["release"] == "1.13.1" + assert source["contract_version"] == "2.3.1" + for name, entry in source["files"].items(): + digest = hashlib.sha256((FIXTURES / name).read_bytes()).hexdigest() + 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 + ) + config_version = details.metadata.config_version if details.metadata else None + assert 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"] + + +@pytest.mark.parametrize("case", VALID_RESPONSES, ids=lambda case: case["id"]) +def test_valid_producer_responses_are_retained_as_sent(case): + response = _build(RESPONSES, case) + records = copy.deepcopy(response["flags"]) + flags = normalize_flags_response(response)["flags"] + + 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 + assert flag.reason.code == record["reason"]["code"] + assert flag.reason.condition_index == record["reason"]["condition_index"] + assert isinstance(flag.metadata, FlagMetadata) + for field, value in record["metadata"].items(): + if field != "payload": + assert getattr(flag.metadata, field) == value, field + + +def _typed_response(values: Dict[str, Any]) -> Dict[str, Any]: + flags = {} + for index, (key, value) in enumerate(values.items()): + flags[key] = { + "key": key, + "value": value, + "reason": { + "code": "targeting_match", + "condition_index": 0, + "description": "Matched rule 1", + }, + "metadata": { + "id": 300 + index, + "version": 2, + "config_version": 2, + "description": None, + "payload": None, + "has_experiment": False, + "rule_type": "targeted_release", + "rule_id": f"30000000-0000-4000-8000-00000000000{index}", + }, + } + return { + "flags": flags, + "errorsWhileComputingFlags": False, + "requestId": "30000000-0000-4000-8000-000000000000", + "evaluatedAt": 1800000000000, + } + + +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, + "object": lambda snapshot: snapshot.get_object_details, +} +DEFAULTS = {"boolean": True, "string": "default", "number": -1, "object": {"d": 1}} +TYPE_OF = { + "boolean-flag": "boolean", + "false-flag": "boolean", + "string-flag": "string", + "empty-string-flag": "string", + "integer-flag": "number", + "zero-flag": "number", + "float-flag": "number", + "object-flag": "object", + "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.reason is not None and details.reason.code == "targeting_match" + assert details.metadata is not None and details.metadata.config_version == 2 + if TYPE_OF.get(key) == value_type: + assert details.value == value + assert type(details.value) is type(value) + assert details.error_code is None + 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 + else: + assert details.value == default + assert details.error_code is FlagEvaluationErrorCode.TYPE_MISMATCH + + +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 not None + assert (details.reason.code, details.reason.condition_index) == ( + "experiment_split", + 0, + ) + assert details.metadata is not None + assert details.metadata.rule_type == "experiment" + assert details.metadata.rule_id == "10000000-0000-4000-8000-000000000001" + assert details.metadata.experiment_id == 301 + assert details.metadata.variant_key == "arm_a" + + +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, + error_code=FlagEvaluationErrorCode.FLAG_NOT_FOUND, + error_message="Flag 'missing-flag' is not in the evaluation.", + ) + + +@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 + + +def _record(**overrides: Any) -> Dict[str, Any]: + record: Dict[str, Any] = { + "key": "garden-layout", + "value": "compact", + "reason": {"code": "targeting_match", "condition_index": 0}, + "metadata": { + "id": 201, + "version": 7, + "config_version": 2, + "has_experiment": False, + "payload": None, + "rule_type": "targeted_release", + "rule_id": "10000000-0000-4000-8000-000000000001", + }, + } + for path, value in overrides.items(): + *parents, last = path.split(".") + target = record + for token in parents: + target = target[token] + target[last] = value + return record + + +@pytest.mark.parametrize( + "path, value", + [ + ("value", []), + ("value", float("nan")), + ("failed", "true"), + ("reason", "targeting_match"), + ("reason.code", 3), + ("reason.condition_index", "0"), + ("reason.condition_index", 0.5), + ("reason.description", ["Matched"]), + ("metadata", []), + ("metadata.id", "201"), + ("metadata.version", True), + ("metadata.config_version", "2"), + ("metadata.description", 1), + ("metadata.has_experiment", "false"), + ("metadata.rule_type", 2), + ("metadata.rule_id", 3), + ("metadata.experiment_id", "301"), + ("metadata.variant_key", True), + ("metadata.holdout_id", 4.5), + ("metadata.forced_variant", "yes"), + ], +) +def test_a_known_field_with_the_wrong_type_fails_only_that_record(path, value): + response = { + "flags": { + "garden-layout": _record(**{path: value}), + "sibling": _record(**{"key": "sibling"}), + } + } + + 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()) == ( + False, + None, + False, + ) + assert path.split(".")[0] in (malformed.error_message or "") + sibling = flags["sibling"] + assert sibling.error_code is None + assert sibling.value == "compact" + + +def test_a_record_that_is_not_an_object_fails_only_that_record(): + response = {"flags": {"broken": ["compact"], "sibling": _record(key="sibling")}} + + flags = normalize_flags_response(response)["flags"] + + assert flags["broken"].error_code is FlagEvaluationErrorCode.PARSE_ERROR + assert flags["sibling"].value == "compact" + + +def test_unknown_fields_and_null_optional_fields_are_ignored(): + record = _record(**{"future_field": {"nested": True}}) + record["reason"]["future_reason"] = 1 + record["metadata"].update(future_metadata=[1], experiment_id=None, holdout_id=None) + record["conditions"] = "not read" + + flag = normalize_flags_response({"flags": {"garden-layout": record}})["flags"][ + "garden-layout" + ] + + assert flag.error_code is None + assert flag.value == "compact" + assert isinstance(flag.metadata, FlagMetadata) + assert flag.metadata.experiment_id is None + + +def test_a_record_without_value_is_read_as_an_older_server_record(): + # An older server sends `enabled` and `variant`. Any rule context it carries + # is discarded, and the config version reads as 1. + record = { + "key": "garden-layout", + "enabled": True, + "variant": "compact", + "reason": {"code": "condition_match", "condition_index": 0}, + "metadata": { + "id": 202, + "version": 9, + "payload": None, + "config_version": 2, + "rule_type": "experiment", + "rule_id": "10000000-0000-4000-8000-000000000001", + "experiment_id": 301, + "variant_key": "compact", + }, + } + + flag = normalize_flags_response({"flags": {"garden-layout": record}})["flags"][ + "garden-layout" + ] + + assert flag.value == "compact" + assert isinstance(flag.metadata, FlagMetadata) + assert flag.metadata.config_version == 1 + assert flag.metadata.rule_type is None + assert flag.metadata.rule_id is None + assert flag.metadata.experiment_id is None + assert flag.metadata.variant_key is None + + +def test_a_failed_older_server_record_returns_the_default(): + record = { + "key": "garden-layout", + "enabled": False, + "variant": None, + "failed": True, + "reason": { + "code": "timeout", + "condition_index": None, + "description": "Timed out", + }, + "metadata": {"id": 202, "version": 9, "payload": None}, + } + + flag = normalize_flags_response({"flags": {"garden-layout": record}})["flags"][ + "garden-layout" + ] + + assert flag.error_code is FlagEvaluationErrorCode.GENERAL + assert flag.error_message == "Timed out" + + +def test_feature_flag_value_defaults_to_the_legacy_value(): + assert FeatureFlag.from_value_and_payload("a", "control", None).value == "control" + assert FeatureFlag.from_value_and_payload("a", True, None).value is True + assert FeatureFlag.from_value_and_payload("a", False, None).value is False + + +# Legacy accessor parity: a v3 record and the equivalent older-server record must give +# identical legacy results. + + +def _legacy_maps(response: Dict[str, Any]) -> Dict[str, Any]: + maps = to_flags_and_payloads(normalize_flags_response(copy.deepcopy(response))) + payloads = maps["featureFlagPayloads"] or {} + return { + "featureFlags": maps["featureFlags"], + "featureFlagPayloads": { + key: json.loads(value) for key, value in payloads.items() + }, + } + + +def _v3_record_for_v1_outcome(outcome: Dict[str, Any]) -> Dict[str, Any]: + """The v3 record of a v1 flag: ``value`` is the variant, or ``enabled`` without one.""" + if outcome["failed"]: + value = None + elif outcome["variant"] is not None: + value = outcome["variant"] + else: + value = outcome["enabled"] + metadata: Dict[str, Any] = { + "id": outcome["flag_id"], + "version": outcome["flag_version"], + "config_version": 1, + "payload": outcome["payload"], + "has_experiment": False, + } + if outcome["variant"] is not None: + metadata["variant_key"] = outcome["variant"] + record = { + "key": outcome["key"], + "value": value, + "reason": outcome["reason"], + "metadata": metadata, + } + if outcome["failed"]: + record["failed"] = True + return record + + +@pytest.mark.parametrize( + "case", LEGACY_PROJECTION["cases"], ids=lambda case: case["id"] +) +def test_v1_flags_render_the_same_legacy_values_from_both_shapes(case): + v2_response = case["projections"]["flags_v2"] + v1_maps = case["projections"]["flags_v1"] + outcome = case["outcome"] + v3_flags = ( + {} + if outcome["omitted"] + else {outcome["key"]: _v3_record_for_v1_outcome(outcome)} + ) + v3_response = { + "errorsWhileComputingFlags": v2_response["errorsWhileComputingFlags"], + "flags": v3_flags, + } + expected = { + "featureFlags": v1_maps["featureFlags"], + "featureFlagPayloads": { + key: json.loads(value) + for key, value in v1_maps["featureFlagPayloads"].items() + }, + } + + assert _legacy_maps(v2_response) == expected + assert _legacy_maps(v3_response) == expected + + +VALUE_SUCCESS_CASES = [ + case for case in VALUE_CORPUS["cases"] if case["expected"]["status"] == "success" +] + + +def _value_case_records(case: Dict[str, Any]) -> tuple[Dict[str, Any], Dict[str, Any]]: + expected, legacy = case["expected"], case["legacy"] + rule = expected.get("rule") + reason = { + "code": expected["reason"], + "condition_index": rule["index"] if rule else None, + "description": "Evaluated", + } + metadata = {"id": 401, "version": 3, "description": None, "has_experiment": False} + v3_metadata = {**metadata, "config_version": 2, "payload": None} + if rule: + v3_metadata.update(rule_type=rule["rule_type"], rule_id=rule["id"]) + payload = legacy["payload"] + v2_record = { + "key": "typed-flag", + "enabled": legacy["enabled"], + "variant": legacy["variant"], + "reason": reason, + "metadata": { + **metadata, + "payload": None if payload is None else json.dumps(payload), + }, + } + v3_record = { + "key": "typed-flag", + "value": expected["value"], + "reason": reason, + "metadata": v3_metadata, + } + return v2_record, v3_record + + +@pytest.mark.parametrize("case", VALUE_SUCCESS_CASES, ids=lambda case: case["id"]) +def test_typed_values_render_the_corpus_legacy_cells_from_both_shapes(case): + v2_record, v3_record = _value_case_records(case) + legacy = case["legacy"] + expected = { + "featureFlags": {"typed-flag": legacy["value"]}, + "featureFlagPayloads": {} + if legacy["payload"] is None + else {"typed-flag": legacy["payload"]}, + } + + for record in (v2_record, v3_record): + maps = _legacy_maps({"flags": {"typed-flag": record}}) + assert maps == expected + assert type(maps["featureFlags"]["typed-flag"]) is type(legacy["value"]) + flag = normalize_flags_response( + {"flags": {"typed-flag": copy.deepcopy(record)}} + )["flags"]["typed-flag"] + assert (flag.enabled, flag.variant) == (legacy["enabled"], legacy["variant"]) + result = FeatureFlagResult.from_flag_details(flag) + assert result is not None + assert result.get_value() == legacy["value"] + assert result.payload == legacy["payload"] + + +def test_number_and_object_payloads_are_compact_json_in_wire_order(): + response = _typed_response( + {"number": 12.5, "object": {"b": "ü", "a": [1, True, None]}} + ) + + payloads = to_flags_and_payloads(normalize_flags_response(response))[ + "featureFlagPayloads" + ] + + assert payloads == {"number": "12.5", "object": '{"b":"ü","a":[1,true,null]}'} + + +# Every legacy getter and event, end to end: a v2-shaped and a v3-shaped response for +# the same flags give identical results. + +PARITY_FLAGS = [ + # key, v2 record fields, v3 value + ("bool-on", {"enabled": True, "variant": None}, True), + ("bool-off", {"enabled": False, "variant": None}, False), + ("v1-variant", {"enabled": True, "variant": "control"}, "control"), + ("v2-string", {"enabled": True, "variant": "compact"}, "compact"), + ("v2-null", {"enabled": False, "variant": None}, None), + ("v2-number", {"enabled": True, "variant": None}, 12.5), + ("v2-object", {"enabled": True, "variant": None}, {"layout": "compact"}), +] +PAYLOADS = {"v1-variant": '{"columns": 2}', "v2-number": "12.5"} +PAYLOADS["v2-object"] = '{"layout":"compact"}' + + +def _parity_response(shape: str) -> Dict[str, Any]: + flags = {} + for index, (key, fields, value) in enumerate(PARITY_FLAGS): + metadata: Dict[str, Any] = { + "id": 500 + index, + "version": 4, + "description": None, + "has_experiment": key == "v1-variant", + } + reason = { + "code": "condition_match", + "condition_index": 0, + "description": "Matched condition set 1", + } + if shape == "v2": + payload = PAYLOADS.get(key) + flags[key] = { + "key": key, + **fields, + "reason": reason, + "metadata": {**metadata, "payload": payload}, + } + else: + config_version = 1 if key.startswith(("bool", "v1")) else 2 + payload = PAYLOADS.get(key) if config_version == 1 else None + flags[key] = { + "key": key, + "value": value, + "reason": reason, + "metadata": { + **metadata, + "config_version": config_version, + "payload": payload, + }, + } + return { + "flags": flags, + "errorsWhileComputingFlags": False, + "requestId": "50000000-0000-4000-8000-000000000000", + "evaluatedAt": 1800000000000, + } + + +def _flag_event_properties(message: Dict[str, Any]) -> Dict[str, Any]: + return { + key: value + for key, value in message["properties"].items() + if key.startswith("$feature") + or key in ("$active_feature_flags", "locally_evaluated") + } + + +def _legacy_outputs(shape: str) -> Dict[str, Any]: + client = Client(FAKE_TEST_API_KEY, send=False) + outputs: Dict[str, Any] = {} + events: List[Any] = [] + keys = [key for key, _, _ in PARITY_FLAGS] + + def response(*args: Any, **kwargs: Any) -> Dict[str, Any]: + return _parity_response(shape) + + def enqueue(message: Dict[str, Any], *args: Any, **kwargs: Any) -> Optional[str]: + events.append((message["event"], _flag_event_properties(message))) + return None + + try: + with ( + mock.patch("posthog.client.flags", side_effect=response), + mock.patch.object(client, "_enqueue", side_effect=enqueue), + pytest.warns(DeprecationWarning), + ): + outputs["get_feature_variants"] = client.get_feature_variants(DISTINCT_ID) + outputs["get_feature_payloads"] = client.get_feature_payloads(DISTINCT_ID) + outputs["get_feature_flags_and_payloads"] = ( + client.get_feature_flags_and_payloads(DISTINCT_ID) + ) + outputs["get_all_flags"] = client.get_all_flags(DISTINCT_ID) + outputs["get_all_flags_and_payloads"] = client.get_all_flags_and_payloads( + DISTINCT_ID + ) + for key in keys + ["missing"]: + result = client.get_feature_flag_result(key, DISTINCT_ID) + outputs[f"get_feature_flag_result:{key}"] = ( + None + if result is None + else (result.enabled, result.variant, result.payload, result.reason) + ) + outputs[f"get_feature_flag:{key}"] = client.get_feature_flag( + key, "other-user" + ) + outputs[f"feature_enabled:{key}"] = client.feature_enabled( + key, "third-user" + ) + outputs[f"get_feature_flag_payload:{key}"] = ( + client.get_feature_flag_payload(key, DISTINCT_ID) + ) + snapshot = client.evaluate_flags("snapshot-user") + for key in keys + ["missing"]: + outputs[f"snapshot.is_enabled:{key}"] = snapshot.is_enabled(key) + outputs[f"snapshot.get_flag:{key}"] = snapshot.get_flag(key) + outputs[f"snapshot.get_flag_payload:{key}"] = snapshot.get_flag_payload( + key + ) + client.capture( + "snapshot-event", distinct_id="snapshot-user", flags=snapshot + ) + client.capture( + "fetched-event", distinct_id="capture-user", send_feature_flags=True + ) + finally: + client.shutdown() + outputs["events"] = events + return outputs + + +def test_every_legacy_getter_and_event_is_identical_for_both_shapes(): + v2_outputs = _legacy_outputs("v2") + v3_outputs = _legacy_outputs("v3") + + assert v3_outputs == v2_outputs + # Spot-check the legacy rendering itself. + assert v3_outputs["get_all_flags"] == { + "bool-on": True, + "bool-off": False, + "v1-variant": "control", + "v2-string": "compact", + "v2-null": False, + "v2-number": True, + "v2-object": True, + } + assert v3_outputs["get_feature_flag_payload:v2-object"] == {"layout": "compact"} + assert v3_outputs["snapshot.get_flag_payload:v2-number"] == 12.5 + + +def test_feature_flag_called_properties_are_unchanged_for_a_v1_flag(client): + response = _build(READERS, {"template": "v1"}) + with ( + mock.patch("posthog.client.flags", return_value=response), + mock.patch.object(client, "capture") as capture, + ): + result = client.get_feature_flag_result("garden-layout", DISTINCT_ID) + + assert result is not None + assert (result.enabled, result.variant, result.payload) == ( + True, + "compact", + {"columns": 2}, + ) + capture.assert_called_once_with( + "$feature_flag_called", + distinct_id=DISTINCT_ID, + properties={ + "$feature_flag": "garden-layout", + "$feature_flag_response": "compact", + "locally_evaluated": False, + "$feature/garden-layout": "compact", + "$feature_flag_payload": {"columns": 2}, + "$feature_flag_request_id": "20000000-0000-4000-8000-000000000002", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_version": 9, + "$feature_flag_id": 202, + "$feature_flag_has_experiment": False, + }, + groups={}, + disable_geoip=None, + ) + + +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" + assert snapshot.only_accessed().keys == ["string-flag"] + + +async def test_async_client_reads_the_v3_response(): + with mock.patch( + "posthog.async_client._async_flags", + 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_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 308339319..9d26d4b38 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -1,11 +1,25 @@ import json import logging +import math from dataclasses import dataclass 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") @@ -137,11 +151,22 @@ class FlagMetadata: Attributes: id: Numeric feature flag ID. - payload: Payload configured for the matched flag value, if any. + payload: Payload configured for the matched flag value, if any. For a + number or object value it is the value encoded as JSON, the way + servers before ``/flags?v=3`` sent it. version: Feature flag version. description: Feature flag description. has_experiment: Whether the flag has a linked experiment. ``None`` when the server does not report the field (older deployments). + config_version: Configuration format of the flag. Responses without a + typed ``value`` always read as ``1``. + rule_type: Type of the rule that produced the result, when one did. + rule_id: ID of the rule that produced the result, when one did. + experiment_id: ID of the experiment behind the result, when there is one. + variant_key: Key of the assigned variant, when one was assigned. + holdout_id: ID of the holdout that excluded the subject, when one did. + forced_variant: Whether a condition-level variant override produced the + result. """ id: int @@ -149,9 +174,21 @@ class FlagMetadata: version: int description: str has_experiment: Optional[bool] = None + config_version: Optional[int] = None + rule_type: Optional[str] = None + rule_id: Optional[str] = None + experiment_id: Optional[int] = None + variant_key: Optional[str] = None + holdout_id: Optional[int] = None + forced_variant: Optional[bool] = None @classmethod def from_json(cls, resp: Any) -> Union["FlagMetadata", LegacyFlagMetadata]: + """Parse the metadata of a record without a typed ``value``. + + Such a record comes from a server before ``/flags?v=3``, so only the + fields that response carries are read, and ``config_version`` is ``1``. + """ if not resp: return LegacyFlagMetadata(payload=None) raw_has_experiment = resp.get("has_experiment") @@ -163,19 +200,66 @@ def from_json(cls, resp: Any) -> Union["FlagMetadata", LegacyFlagMetadata]: has_experiment=raw_has_experiment if isinstance(raw_has_experiment, bool) else None, + config_version=1, ) +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.""" + + +class _DerivedValue: + """Default of ``FeatureFlag.value``: derive the value from ``variant`` and ``enabled``.""" + + def __repr__(self) -> str: + return "" + + +_DERIVED_VALUE: Any = _DerivedValue() + +# Reason codes that carry an error for a config version 2 flag. +_V2_ERROR_REASON_CODES = { + "missing_group_key": FlagEvaluationErrorCode.INVALID_CONTEXT, + "dependency_error": FlagEvaluationErrorCode.GENERAL, + "error": FlagEvaluationErrorCode.GENERAL, +} + + @dataclass(frozen=True) class FeatureFlag: """Detailed feature flag evaluation returned by the flags API. + ``value``, ``failed``, ``reason`` and ``metadata`` are the evaluation details. + ``enabled``, ``variant`` and ``metadata.payload`` are the value rendered the way + responses before ``/flags?v=3`` carried it, which the legacy getters return. + Attributes: key: Feature flag key. enabled: Whether the flag is enabled for the evaluated user or group. variant: Variant key for multivariate flags, otherwise ``None``. reason: Optional reason metadata explaining the result. metadata: Payload and other metadata returned by the API. + value: The typed flag value: a boolean, string, number, object, or + ``None`` when the flag has no value. Defaults to ``variant``, or + ``enabled`` when there is no variant. + failed: Whether the flag failed to evaluate, or its record could not be + read. + error_code: Why the flag has no usable value, when it has an error. + error_message: Human-readable detail for ``error_code``. """ key: str @@ -183,12 +267,33 @@ class FeatureFlag: 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 + + def __post_init__(self) -> None: + if self.value is _DERIVED_VALUE: + object.__setattr__( + self, + "value", + self.variant if self.variant is not None else self.enabled, + ) def get_value(self) -> FlagValue: return self.variant or self.enabled @classmethod def from_json(cls, resp: Any) -> "FeatureFlag": + """Parse one flag record of a ``/flags`` response. + + A record with a ``value`` member is a ``/flags?v=3`` record. A record + without one comes from an older server: its value is ``variant``, or + ``enabled`` when there is no variant, and its config version is ``1``. + """ + if "value" in resp: + return _parse_typed_record(resp.get("key"), resp) + reason = None if resp.get("reason"): reason = FlagReason.from_json(resp.get("reason")) @@ -199,12 +304,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=failed, + error_code=error_code, + error_message=error_message, ) @classmethod @@ -223,6 +333,249 @@ def from_value_and_payload( ) +class _MalformedRecord(Exception): + """A known field of a flag record has the wrong JSON type.""" + + +def _read_int(value: Any, field: str) -> Optional[int]: + # JSON has one number type, so an integral number such as 2.0 is an integer. + if value is None: + return None + if isinstance(value, int) and not isinstance(value, bool): + return value + if isinstance(value, float) and math.isfinite(value) and value.is_integer(): + return int(value) + raise _MalformedRecord(field) + + +def _read_typed(value: Any, field: str, expected: type) -> Any: + if value is None or isinstance(value, expected): + return value + raise _MalformedRecord(field) + + +def _read_value(value: Any) -> Any: + if value is None or isinstance(value, (bool, str, dict)): + return value + if isinstance(value, int) or (isinstance(value, float) and math.isfinite(value)): + return value + 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)``. + + A string is the variant, ``None`` is disabled, and a number or object is enabled + with the value as a JSON-encoded payload, as servers before ``/flags?v=3`` sent it. + """ + if value is None or isinstance(value, bool): + return bool(value), None, None + if isinstance(value, str): + return True, value, None + return True, None, json.dumps(value, separators=(",", ":"), ensure_ascii=False) + + +def _parse_typed_record(key: str, resp: Any) -> FeatureFlag: + """Parse a ``/flags?v=3`` record, which carries a typed ``value``. + + Unknown fields are ignored. A known field with the wrong JSON type fails only + this record, with ``PARSE_ERROR``. An absent or null field reads as absent, + except ``value``, where null means the flag has no value. + """ + try: + if not isinstance(resp, dict): + raise _MalformedRecord("record") + value = _read_value(resp.get("value")) + failed = _read_typed(resp.get("failed"), "failed", bool) is True + + reason = None + raw_reason = _read_typed(resp.get("reason"), "reason", dict) + if raw_reason is not None: + reason = FlagReason( + code=_read_typed(raw_reason.get("code"), "reason.code", str) or "", + condition_index=_read_int( + raw_reason.get("condition_index"), "reason.condition_index" + ), + description=_read_typed( + raw_reason.get("description"), "reason.description", str + ) + or "", + ) + + if failed: + # A failed record has no value, whatever it carries. + value = None + enabled, variant, encoded_payload = _legacy_rendering(value) + + metadata: Union[FlagMetadata, LegacyFlagMetadata] + raw_metadata = _read_typed(resp.get("metadata"), "metadata", dict) + if raw_metadata is None: + metadata = LegacyFlagMetadata(payload=encoded_payload) + else: + payload = raw_metadata.get("payload") + metadata = FlagMetadata( + id=_read_int(raw_metadata.get("id"), "metadata.id") or 0, + payload=payload if payload is not None else encoded_payload, + version=_read_int(raw_metadata.get("version"), "metadata.version") or 0, + description=_read_typed( + raw_metadata.get("description", ""), "metadata.description", str + ), + has_experiment=_read_typed( + raw_metadata.get("has_experiment"), "metadata.has_experiment", bool + ), + config_version=_read_int( + raw_metadata.get("config_version"), "metadata.config_version" + ), + rule_type=_read_typed( + raw_metadata.get("rule_type"), "metadata.rule_type", str + ), + rule_id=_read_typed( + raw_metadata.get("rule_id"), "metadata.rule_id", str + ), + experiment_id=_read_int( + raw_metadata.get("experiment_id"), "metadata.experiment_id" + ), + variant_key=_read_typed( + raw_metadata.get("variant_key"), "metadata.variant_key", str + ), + holdout_id=_read_int( + raw_metadata.get("holdout_id"), "metadata.holdout_id" + ), + forced_variant=_read_typed( + raw_metadata.get("forced_variant"), "metadata.forced_variant", bool + ), + ) + except _MalformedRecord as error: + return FeatureFlag( + key=key, + enabled=False, + variant=None, + reason=None, + 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, + variant=variant, + reason=reason, + metadata=metadata, + value=value, + failed=failed, + error_code=error_code, + error_message=error_message, + ) + + +@dataclass(frozen=True) +class FlagEvaluationDetails(Generic[_T]): + """The result of a typed flag accessor such as ``get_boolean_details``. + + Attributes: + key: Feature flag key. + value: The flag value, or the caller default when the flag has no value + of the requested type. + variant: Key of the assigned variant, when the server reported one. + reason: The reason the server gave for the result, when there is one. + metadata: The flag metadata the server returned, when there is any. + 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``. + """ + + key: str + value: _T + variant: Optional[str] = None + reason: Optional[FlagReason] = None + metadata: Optional[FlagMetadata] = None + error_code: Optional[FlagEvaluationErrorCode] = None + error_message: Optional[str] = None + + +def _is_number(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +_FLAG_VALUE_TYPES: Dict[str, Callable[[Any], bool]] = { + "boolean": lambda value: isinstance(value, bool), + "string": lambda value: isinstance(value, str), + "number": _is_number, + "object": lambda value: isinstance(value, dict), +} + + +def _resolve_flag_details( + key: str, flag: Optional[FeatureFlag], default_value: _T, value_type: str +) -> FlagEvaluationDetails[_T]: + """Resolve a flag record for a typed accessor without coercing its value. + + The caller default is returned for an absent flag, a failed or unreadable record, + a ``None`` value, and a value of another type. ``False`` read as another type + returns the default without an error. + """ + if flag is None: + return FlagEvaluationDetails( + key=key, + value=default_value, + 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 + variant = metadata.variant_key if metadata else None + + def resolved( + value: Any, + error_code: Optional[FlagEvaluationErrorCode] = None, + error_message: Optional[str] = None, + ) -> FlagEvaluationDetails[Any]: + return FlagEvaluationDetails( + key=key, + value=value, + variant=variant, + reason=flag.reason, + metadata=metadata, + error_code=error_code, + error_message=error_message, + ) + + if flag.error_code is not None: + return resolved(default_value, flag.error_code, flag.error_message) + accepts = _FLAG_VALUE_TYPES[value_type] + if accepts(flag.value): + return resolved(flag.value) + if flag.value is None or flag.value is False: + return resolved(default_value) + return resolved( + default_value, + FlagEvaluationErrorCode.TYPE_MISMATCH, + f"Flag '{key}' does not have a {value_type} value.", + ) + + class FlagsResponse(TypedDict, total=False): """Normalized response from the PostHog feature flags API.""" @@ -340,7 +693,7 @@ def normalize_flags_response(resp: Any) -> FlagsResponse: Normalize the response from the flags API endpoint into a FlagsResponse. Args: - resp: A v1 or v2 response from the flags API endpoint. + resp: A v1, v2 or v3 response from the flags API endpoint. Returns: A FlagsResponse containing feature flags and their details. @@ -353,6 +706,9 @@ def normalize_flags_response(resp: Any) -> FlagsResponse: for key, value in flags.items(): if isinstance(value, FeatureFlag): continue + if not isinstance(value, dict): + flags[key] = _parse_typed_record(key, value) + continue value["key"] = key flags[key] = FeatureFlag.from_json(value) else: diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 51ee00d9d..c4192f455 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -21,6 +21,8 @@ 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.FlagValue -> posthog.types.FlagValue alias posthog.FlagsAndPayloads -> posthog.types.FlagsAndPayloads alias posthog.ID_TYPES -> posthog.args.ID_TYPES @@ -317,6 +319,8 @@ alias posthog.exception_capture.BucketedRateLimiter -> posthog.bucketed_rate_lim 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_flags.FlagValue -> posthog.types.FlagValue alias posthog.feature_flags.convert_to_datetime_aware -> posthog.utils.convert_to_datetime_aware @@ -1033,9 +1037,13 @@ attribute posthog.traces = None attribute posthog.types.BeforeSendCallback = Callable[[dict[str, Any]], Optional[dict[str, Any]]] attribute posthog.types.BeforeSpanSendCallback = Callable[[dict[str, Any]], Optional[dict[str, Any]]] attribute posthog.types.FeatureFlag.enabled: bool +attribute posthog.types.FeatureFlag.error_code: Optional[FlagEvaluationErrorCode] = None +attribute posthog.types.FeatureFlag.error_message: Optional[str] = None +attribute posthog.types.FeatureFlag.failed: bool = False attribute posthog.types.FeatureFlag.key: str attribute posthog.types.FeatureFlag.metadata: Union[FlagMetadata, LegacyFlagMetadata] attribute posthog.types.FeatureFlag.reason: Optional[FlagReason] +attribute posthog.types.FeatureFlag.value: Any = _DERIVED_VALUE attribute posthog.types.FeatureFlag.variant: Optional[str] attribute posthog.types.FeatureFlagError.CONNECTION_ERROR = 'connection_error' attribute posthog.types.FeatureFlagError.ERRORS_WHILE_COMPUTING = 'errors_while_computing_flags' @@ -1051,10 +1059,29 @@ 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.key: str +attribute posthog.types.FlagEvaluationDetails.metadata: Optional[FlagMetadata] = None +attribute posthog.types.FlagEvaluationDetails.reason: Optional[FlagReason] = None +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.FlagMetadata.config_version: Optional[int] = None attribute posthog.types.FlagMetadata.description: str +attribute posthog.types.FlagMetadata.experiment_id: Optional[int] = None +attribute posthog.types.FlagMetadata.forced_variant: Optional[bool] = None attribute posthog.types.FlagMetadata.has_experiment: Optional[bool] = None +attribute posthog.types.FlagMetadata.holdout_id: Optional[int] = None attribute posthog.types.FlagMetadata.id: int attribute posthog.types.FlagMetadata.payload: Optional[str] +attribute posthog.types.FlagMetadata.rule_id: Optional[str] = None +attribute posthog.types.FlagMetadata.rule_type: Optional[str] = None +attribute posthog.types.FlagMetadata.variant_key: Optional[str] = None attribute posthog.types.FlagMetadata.version: int attribute posthog.types.FlagReason.code: str attribute posthog.types.FlagReason.condition_index: Optional[int] @@ -1222,11 +1249,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]) +class posthog.types.FeatureFlag(key: str, enabled: bool, variant: Optional[str], reason: Optional[FlagReason], metadata: Union[FlagMetadata, LegacyFlagMetadata], value: Any = _DERIVED_VALUE, failed: bool = False, error_code: Optional[FlagEvaluationErrorCode] = None, error_message: Optional[str] = None) class posthog.types.FeatureFlagError class posthog.types.FeatureFlagEvaluationRuntime class posthog.types.FeatureFlagResult(key: str, enabled: bool, variant: Optional[str], payload: Optional[Any], reason: Optional[str]) -class posthog.types.FlagMetadata(id: int, payload: Optional[str], version: int, description: str, has_experiment: Optional[bool] = None) +class posthog.types.FlagEvaluationDetails(key: str, value: _T, variant: Optional[str] = None, reason: Optional[FlagReason] = None, metadata: Optional[FlagMetadata] = None, error_code: Optional[FlagEvaluationErrorCode] = None, error_message: Optional[str] = None) +class posthog.types.FlagEvaluationErrorCode +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, experiment_id: Optional[int] = None, variant_key: Optional[str] = None, holdout_id: Optional[int] = None, forced_variant: Optional[bool] = None) class posthog.types.FlagReason(code: str, condition_index: Optional[int], description: str) class posthog.types.FlagsAndPayloads class posthog.types.FlagsResponse @@ -1650,8 +1679,16 @@ 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_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 811c6c233b26efbde81521af1c6a05eea9e1ce5e Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Thu, 15 Oct 2026 13:55:27 +0200 Subject: [PATCH 02/35] feat(flags): drop experiment_id from v3 flag metadata A v3 record for a flag in the new format carries no experiment ID: an experiment is linked to its rule on the server, by rule ID. A complete experiment split is config version 2, reason experiment_split, rule type experiment, a rule ID and a variant key. - Remove experiment_id from FlagMetadata. An experiment_id key in a record is now ignored like any other unknown metadata key, whatever its type. - The vendored harness 1.13.1 fixtures still carry the field. Two reader cases that depend on it are overridden in the test until the fixtures are re-pinned to a contract without it. --- posthog/test/test_flags_v3_response.py | 49 +++++++++++++++++++++----- posthog/types.py | 5 --- references/public_api_snapshot.txt | 3 +- 3 files changed, 41 insertions(+), 16 deletions(-) diff --git a/posthog/test/test_flags_v3_response.py b/posthog/test/test_flags_v3_response.py index 6990f8172..9082d79dc 100644 --- a/posthog/test/test_flags_v3_response.py +++ b/posthog/test/test_flags_v3_response.py @@ -104,11 +104,25 @@ def _complete_split(details: FlagEvaluationDetails[Any]) -> bool: and details.reason.code == "experiment_split" and metadata.rule_type == "experiment" and metadata.rule_id is not None - and metadata.experiment_id is not None and metadata.variant_key is not None ) +# The pinned contract still lists `metadata.experiment_id` as a known field. Records no +# longer carry an experiment ID, and the reader ignores the key like any unknown one, so +# these cases now read as a complete split. Drop the overrides when the fixtures are +# re-pinned to a contract without the field. +READER_OVERRIDES: Dict[str, Dict[str, Any]] = { + "readers.partial_split_missing_experiment_id": {"experiment_exposure": True}, + "readers.split_wrong_type_experiment_id": { + "value": False, + "config_version": 2, + "experiment_exposure": True, + "error_code": None, + }, +} + + def test_vendored_fixtures_match_the_recorded_release_digests(): source = _load("SOURCE.json") assert source["release"] == "1.13.1" @@ -118,10 +132,14 @@ def test_vendored_fixtures_match_the_recorded_release_digests(): assert digest == entry["sha256"], name +def test_reader_overrides_name_vendored_cases(): + assert set(READER_OVERRIDES) <= {case["id"] for case in READERS["cases"]} + + @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"] + reader = {**case["reader"], **READER_OVERRIDES.get(case["id"], {})} key = reader["requested_key"] details = _details(snapshot, key, reader["caller_default"], reader["value"]) @@ -164,7 +182,8 @@ def test_valid_producer_responses_are_retained_as_sent(case): assert flag.reason.condition_index == record["reason"]["condition_index"] assert isinstance(flag.metadata, FlagMetadata) for field, value in record["metadata"].items(): - if field != "payload": + # The pinned fixtures still send `experiment_id`, which is ignored. + if field not in ("payload", "experiment_id"): assert getattr(flag.metadata, field) == value, field @@ -288,7 +307,6 @@ def test_typed_details_carry_the_variant_key_and_context(client): assert details.metadata is not None assert details.metadata.rule_type == "experiment" assert details.metadata.rule_id == "10000000-0000-4000-8000-000000000001" - assert details.metadata.experiment_id == 301 assert details.metadata.variant_key == "arm_a" @@ -392,7 +410,6 @@ def _record(**overrides: Any) -> Dict[str, Any]: ("metadata.has_experiment", "false"), ("metadata.rule_type", 2), ("metadata.rule_id", 3), - ("metadata.experiment_id", "301"), ("metadata.variant_key", True), ("metadata.holdout_id", 4.5), ("metadata.forced_variant", "yes"), @@ -435,7 +452,7 @@ def test_a_record_that_is_not_an_object_fails_only_that_record(): def test_unknown_fields_and_null_optional_fields_are_ignored(): record = _record(**{"future_field": {"nested": True}}) record["reason"]["future_reason"] = 1 - record["metadata"].update(future_metadata=[1], experiment_id=None, holdout_id=None) + record["metadata"].update(future_metadata=[1], holdout_id=None) record["conditions"] = "not read" flag = normalize_flags_response({"flags": {"garden-layout": record}})["flags"][ @@ -445,7 +462,23 @@ def test_unknown_fields_and_null_optional_fields_are_ignored(): assert flag.error_code is None assert flag.value == "compact" assert isinstance(flag.metadata, FlagMetadata) - assert flag.metadata.experiment_id is None + assert flag.metadata.holdout_id is None + + +@pytest.mark.parametrize("experiment_id", [301, "301"]) +def test_an_experiment_id_in_the_metadata_is_ignored(experiment_id): + # Experiments link to rules on the server, so records carry no experiment ID. A + # record that still sends one reads like a record with any other unknown key. + record = _record(**{"metadata.experiment_id": experiment_id}) + + flag = normalize_flags_response({"flags": {"garden-layout": record}})["flags"][ + "garden-layout" + ] + + assert flag.error_code is None + assert flag.value == "compact" + assert isinstance(flag.metadata, FlagMetadata) + assert not hasattr(flag.metadata, "experiment_id") def test_a_record_without_value_is_read_as_an_older_server_record(): @@ -463,7 +496,6 @@ def test_a_record_without_value_is_read_as_an_older_server_record(): "config_version": 2, "rule_type": "experiment", "rule_id": "10000000-0000-4000-8000-000000000001", - "experiment_id": 301, "variant_key": "compact", }, } @@ -477,7 +509,6 @@ def test_a_record_without_value_is_read_as_an_older_server_record(): assert flag.metadata.config_version == 1 assert flag.metadata.rule_type is None assert flag.metadata.rule_id is None - assert flag.metadata.experiment_id is None assert flag.metadata.variant_key is None diff --git a/posthog/types.py b/posthog/types.py index 9d26d4b38..58c1f0cbc 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -162,7 +162,6 @@ class FlagMetadata: typed ``value`` always read as ``1``. rule_type: Type of the rule that produced the result, when one did. rule_id: ID of the rule that produced the result, when one did. - experiment_id: ID of the experiment behind the result, when there is one. variant_key: Key of the assigned variant, when one was assigned. holdout_id: ID of the holdout that excluded the subject, when one did. forced_variant: Whether a condition-level variant override produced the @@ -177,7 +176,6 @@ class FlagMetadata: config_version: Optional[int] = None rule_type: Optional[str] = None rule_id: Optional[str] = None - experiment_id: Optional[int] = None variant_key: Optional[str] = None holdout_id: Optional[int] = None forced_variant: Optional[bool] = None @@ -447,9 +445,6 @@ def _parse_typed_record(key: str, resp: Any) -> FeatureFlag: rule_id=_read_typed( raw_metadata.get("rule_id"), "metadata.rule_id", str ), - experiment_id=_read_int( - raw_metadata.get("experiment_id"), "metadata.experiment_id" - ), variant_key=_read_typed( raw_metadata.get("variant_key"), "metadata.variant_key", str ), diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index c4192f455..d7ab4610d 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -1073,7 +1073,6 @@ attribute posthog.types.FlagEvaluationErrorCode.PARSE_ERROR = 'PARSE_ERROR' attribute posthog.types.FlagEvaluationErrorCode.TYPE_MISMATCH = 'TYPE_MISMATCH' attribute posthog.types.FlagMetadata.config_version: Optional[int] = None attribute posthog.types.FlagMetadata.description: str -attribute posthog.types.FlagMetadata.experiment_id: Optional[int] = None attribute posthog.types.FlagMetadata.forced_variant: Optional[bool] = None attribute posthog.types.FlagMetadata.has_experiment: Optional[bool] = None attribute posthog.types.FlagMetadata.holdout_id: Optional[int] = None @@ -1255,7 +1254,7 @@ 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, variant: Optional[str] = None, reason: Optional[FlagReason] = None, metadata: Optional[FlagMetadata] = None, error_code: Optional[FlagEvaluationErrorCode] = None, error_message: Optional[str] = None) class posthog.types.FlagEvaluationErrorCode -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, experiment_id: Optional[int] = None, variant_key: Optional[str] = None, holdout_id: Optional[int] = None, forced_variant: Optional[bool] = None) +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, holdout_id: Optional[int] = None, forced_variant: Optional[bool] = None) class posthog.types.FlagReason(code: str, condition_index: Optional[int], description: str) class posthog.types.FlagsAndPayloads class posthog.types.FlagsResponse From ae6dcde9fa52bc3f746ed7db96f87d2f3073ef6f Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Thu, 15 Oct 2026 14:24:25 +0200 Subject: [PATCH 03/35] feat(flags): drop holdout_id from v3 flag metadata A v3 record carries no holdout ID: holdouts in the new format are local to their rule and have no ID, and older records never sent one. A holdout result is identified by its rule ID and the reason holdout. - Remove holdout_id from FlagMetadata. A holdout_id key in a record is now ignored like any other unknown metadata key, whatever its type. - The vendored harness 1.13.1 fixtures still send the field. No reader case depends on it; the producer-response test skips it, like experiment_id, until the fixtures are re-pinned to a contract without it. --- posthog/test/test_flags_v3_response.py | 49 +++++++++++++++++--------- posthog/types.py | 5 --- references/public_api_snapshot.txt | 3 +- 3 files changed, 34 insertions(+), 23 deletions(-) diff --git a/posthog/test/test_flags_v3_response.py b/posthog/test/test_flags_v3_response.py index 9082d79dc..aef966786 100644 --- a/posthog/test/test_flags_v3_response.py +++ b/posthog/test/test_flags_v3_response.py @@ -108,10 +108,13 @@ def _complete_split(details: FlagEvaluationDetails[Any]) -> bool: ) -# The pinned contract still lists `metadata.experiment_id` as a known field. Records no -# longer carry an experiment ID, and the reader ignores the key like any unknown one, so -# these cases now read as a complete split. Drop the overrides when the fixtures are -# re-pinned to a contract without the field. +# The pinned contract still lists `metadata.experiment_id` and `metadata.holdout_id` as +# known fields. Records no longer carry either: experiments link to rules on the server, +# and holdouts are rule-local without an ID. The reader ignores both keys like any +# unknown one. Drop these workarounds when the fixtures are re-pinned to a contract +# without the fields. +IGNORED_FIXTURE_METADATA = ("experiment_id", "holdout_id") +# Cases that depend on `experiment_id`, which now read as a complete split. READER_OVERRIDES: Dict[str, Dict[str, Any]] = { "readers.partial_split_missing_experiment_id": {"experiment_exposure": True}, "readers.split_wrong_type_experiment_id": { @@ -132,8 +135,15 @@ def test_vendored_fixtures_match_the_recorded_release_digests(): assert digest == entry["sha256"], name -def test_reader_overrides_name_vendored_cases(): +def test_fixture_workarounds_name_vendored_cases_and_fields(): assert set(READER_OVERRIDES) <= {case["id"] for case in READERS["cases"]} + sent = { + field + for case in VALID_RESPONSES + for record in _build(RESPONSES, case)["flags"].values() + for field in record["metadata"] + } + assert set(IGNORED_FIXTURE_METADATA) <= sent @pytest.mark.parametrize("case", READERS["cases"], ids=lambda case: case["id"]) @@ -182,8 +192,7 @@ def test_valid_producer_responses_are_retained_as_sent(case): assert flag.reason.condition_index == record["reason"]["condition_index"] assert isinstance(flag.metadata, FlagMetadata) for field, value in record["metadata"].items(): - # The pinned fixtures still send `experiment_id`, which is ignored. - if field not in ("payload", "experiment_id"): + if field != "payload" and field not in IGNORED_FIXTURE_METADATA: assert getattr(flag.metadata, field) == value, field @@ -411,7 +420,6 @@ def _record(**overrides: Any) -> Dict[str, Any]: ("metadata.rule_type", 2), ("metadata.rule_id", 3), ("metadata.variant_key", True), - ("metadata.holdout_id", 4.5), ("metadata.forced_variant", "yes"), ], ) @@ -452,7 +460,7 @@ def test_a_record_that_is_not_an_object_fails_only_that_record(): def test_unknown_fields_and_null_optional_fields_are_ignored(): record = _record(**{"future_field": {"nested": True}}) record["reason"]["future_reason"] = 1 - record["metadata"].update(future_metadata=[1], holdout_id=None) + record["metadata"].update(future_metadata=[1], variant_key=None) record["conditions"] = "not read" flag = normalize_flags_response({"flags": {"garden-layout": record}})["flags"][ @@ -462,14 +470,23 @@ def test_unknown_fields_and_null_optional_fields_are_ignored(): assert flag.error_code is None assert flag.value == "compact" assert isinstance(flag.metadata, FlagMetadata) - assert flag.metadata.holdout_id is None + assert flag.metadata.variant_key is None -@pytest.mark.parametrize("experiment_id", [301, "301"]) -def test_an_experiment_id_in_the_metadata_is_ignored(experiment_id): - # Experiments link to rules on the server, so records carry no experiment ID. A - # record that still sends one reads like a record with any other unknown key. - record = _record(**{"metadata.experiment_id": experiment_id}) +@pytest.mark.parametrize( + "field, value", + [ + ("experiment_id", 301), + ("experiment_id", "301"), + ("holdout_id", 401), + ("holdout_id", "401"), + ], +) +def test_experiment_and_holdout_ids_in_the_metadata_are_ignored(field, value): + # Records carry no experiment or holdout ID: experiments link to rules on the + # server, and holdouts are rule-local. A record that still sends one reads like a + # record with any other unknown key. + record = _record(**{f"metadata.{field}": value}) flag = normalize_flags_response({"flags": {"garden-layout": record}})["flags"][ "garden-layout" @@ -478,7 +495,7 @@ def test_an_experiment_id_in_the_metadata_is_ignored(experiment_id): assert flag.error_code is None assert flag.value == "compact" assert isinstance(flag.metadata, FlagMetadata) - assert not hasattr(flag.metadata, "experiment_id") + assert not hasattr(flag.metadata, field) def test_a_record_without_value_is_read_as_an_older_server_record(): diff --git a/posthog/types.py b/posthog/types.py index 58c1f0cbc..78626af38 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -163,7 +163,6 @@ class FlagMetadata: rule_type: Type of the rule that produced the result, when one did. rule_id: ID of the rule that produced the result, when one did. variant_key: Key of the assigned variant, when one was assigned. - holdout_id: ID of the holdout that excluded the subject, when one did. forced_variant: Whether a condition-level variant override produced the result. """ @@ -177,7 +176,6 @@ class FlagMetadata: rule_type: Optional[str] = None rule_id: Optional[str] = None variant_key: Optional[str] = None - holdout_id: Optional[int] = None forced_variant: Optional[bool] = None @classmethod @@ -448,9 +446,6 @@ def _parse_typed_record(key: str, resp: Any) -> FeatureFlag: variant_key=_read_typed( raw_metadata.get("variant_key"), "metadata.variant_key", str ), - holdout_id=_read_int( - raw_metadata.get("holdout_id"), "metadata.holdout_id" - ), forced_variant=_read_typed( raw_metadata.get("forced_variant"), "metadata.forced_variant", bool ), diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index d7ab4610d..8a7c7c4e1 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -1075,7 +1075,6 @@ attribute posthog.types.FlagMetadata.config_version: Optional[int] = None attribute posthog.types.FlagMetadata.description: str attribute posthog.types.FlagMetadata.forced_variant: Optional[bool] = None attribute posthog.types.FlagMetadata.has_experiment: Optional[bool] = None -attribute posthog.types.FlagMetadata.holdout_id: Optional[int] = None attribute posthog.types.FlagMetadata.id: int attribute posthog.types.FlagMetadata.payload: Optional[str] attribute posthog.types.FlagMetadata.rule_id: Optional[str] = None @@ -1254,7 +1253,7 @@ 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, variant: Optional[str] = None, reason: Optional[FlagReason] = None, metadata: Optional[FlagMetadata] = None, error_code: Optional[FlagEvaluationErrorCode] = None, error_message: Optional[str] = None) class posthog.types.FlagEvaluationErrorCode -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, holdout_id: Optional[int] = None, forced_variant: Optional[bool] = None) +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 class posthog.types.FlagsResponse From ab872fa87f55819936e2c37dbba6dede8bcf12b7 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Mon, 26 Oct 2026 13:29:57 +0100 Subject: [PATCH 04/35] feat(flags): request /flags?v=4 and call the typed record v4 Clients already send v=3 and arbitrary v values, so the server selects the typed record only for an exact v=4. Request v=4 and name the record after it in tests, docstrings and the changeset. A server that answers v=4 with the older record (enabled/variant, no value) is still read the same way. --- ...gs-v3-response.md => flags-v4-response.md} | 2 +- posthog/_async_request.py | 2 +- posthog/request.py | 2 +- posthog/test/snapshots/flags_request.json | 2 +- posthog/test/test_async_request.py | 2 +- ..._response.py => test_flags_v4_response.py} | 48 +++++++++---------- posthog/types.py | 14 +++--- 7 files changed, 36 insertions(+), 36 deletions(-) rename .sampo/changesets/{flags-v3-response.md => flags-v4-response.md} (90%) rename posthog/test/{test_flags_v3_response.py => test_flags_v4_response.py} (96%) diff --git a/.sampo/changesets/flags-v3-response.md b/.sampo/changesets/flags-v4-response.md similarity index 90% rename from .sampo/changesets/flags-v3-response.md rename to .sampo/changesets/flags-v4-response.md index 9be4b3e4b..beacb6bc3 100644 --- a/.sampo/changesets/flags-v3-response.md +++ b/.sampo/changesets/flags-v4-response.md @@ -2,4 +2,4 @@ pypi/posthog: minor --- -Request `/flags?v=3` and keep each flag's typed value, reason and metadata. Servers that still send the older response keep working. Existing getters, callbacks and `$feature_flag_called` properties return the same values as before: a number or object value reads as `True`, with the value as its payload. Add `get_boolean_value`, `get_string_value`, `get_number_value` and `get_object_value` to the `evaluate_flags()` result, each with a `_details` form that also returns the reason and 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. +Request `/flags?v=4` and keep each flag's typed value, reason and metadata. Servers that still send the older response keep working. Existing getters, callbacks and `$feature_flag_called` properties return the same values as before: a number or object value reads as `True`, with the value as its payload. Add `get_boolean_value`, `get_string_value`, `get_number_value` and `get_object_value` to the `evaluate_flags()` result, each with a `_details` form that also returns the reason and 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. diff --git a/posthog/_async_request.py b/posthog/_async_request.py index e989c3983..3803c79b5 100644 --- a/posthog/_async_request.py +++ b/posthog/_async_request.py @@ -149,7 +149,7 @@ async def async_flags( _serialize_flags_body, api_key, request_data ) response = await http_client.post( - "/flags/?v=3", content=data, headers=headers, timeout=timeout + "/flags/?v=4", content=data, headers=headers, timeout=timeout ) return await asyncio.to_thread(_process_flags_response, response) except httpx_module.TransportError: diff --git a/posthog/request.py b/posthog/request.py index c28d987c5..c7a0d8e37 100644 --- a/posthog/request.py +++ b/posthog/request.py @@ -329,7 +329,7 @@ def flags( res = post( api_key, host, - "/flags/?v=3", + "/flags/?v=4", gzip, timeout, session=_get_flags_session(), diff --git a/posthog/test/snapshots/flags_request.json b/posthog/test/snapshots/flags_request.json index 2a8fbad6a..5e08287bd 100644 --- a/posthog/test/snapshots/flags_request.json +++ b/posthog/test/snapshots/flags_request.json @@ -28,5 +28,5 @@ "User-Agent": "posthog-python/" }, "timeout": 3, - "url": "https://example.posthog.test/flags/?v=3" + "url": "https://example.posthog.test/flags/?v=4" } diff --git a/posthog/test/test_async_request.py b/posthog/test/test_async_request.py index 062d183c0..a20d099cc 100644 --- a/posthog/test/test_async_request.py +++ b/posthog/test/test_async_request.py @@ -242,7 +242,7 @@ async def test_async_flags_sends_v2_request_payload(): assert result == {"flags": {}} _, args, kwargs = client.calls[0] - assert args == ("/flags/?v=3",) + assert args == ("/flags/?v=4",) payload = json.loads(kwargs["content"]) assert payload["token"] == "project-key" assert "api_key" not in payload diff --git a/posthog/test/test_flags_v3_response.py b/posthog/test/test_flags_v4_response.py similarity index 96% rename from posthog/test/test_flags_v3_response.py rename to posthog/test/test_flags_v4_response.py index aef966786..0d5a7f2f3 100644 --- a/posthog/test/test_flags_v3_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -1,4 +1,4 @@ -"""Reading the ``/flags?v=3`` response, checked against the released wire contract. +"""Reading the ``/flags?v=4`` response, checked against the released wire contract. The files in ``fixtures/feature_flag_rules_v2`` are copied unchanged from a released ``PostHog/posthog-sdk-test-harness`` contract. ``SOURCE.json`` records the release and @@ -557,7 +557,7 @@ def test_feature_flag_value_defaults_to_the_legacy_value(): assert FeatureFlag.from_value_and_payload("a", False, None).value is False -# Legacy accessor parity: a v3 record and the equivalent older-server record must give +# Legacy accessor parity: a v4 record and the equivalent older-server record must give # identical legacy results. @@ -572,8 +572,8 @@ def _legacy_maps(response: Dict[str, Any]) -> Dict[str, Any]: } -def _v3_record_for_v1_outcome(outcome: Dict[str, Any]) -> Dict[str, Any]: - """The v3 record of a v1 flag: ``value`` is the variant, or ``enabled`` without one.""" +def _v4_record_for_v1_outcome(outcome: Dict[str, Any]) -> Dict[str, Any]: + """The v4 record of a v1 flag: ``value`` is the variant, or ``enabled`` without one.""" if outcome["failed"]: value = None elif outcome["variant"] is not None: @@ -607,14 +607,14 @@ def test_v1_flags_render_the_same_legacy_values_from_both_shapes(case): v2_response = case["projections"]["flags_v2"] v1_maps = case["projections"]["flags_v1"] outcome = case["outcome"] - v3_flags = ( + v4_flags = ( {} if outcome["omitted"] - else {outcome["key"]: _v3_record_for_v1_outcome(outcome)} + else {outcome["key"]: _v4_record_for_v1_outcome(outcome)} ) - v3_response = { + v4_response = { "errorsWhileComputingFlags": v2_response["errorsWhileComputingFlags"], - "flags": v3_flags, + "flags": v4_flags, } expected = { "featureFlags": v1_maps["featureFlags"], @@ -625,7 +625,7 @@ def test_v1_flags_render_the_same_legacy_values_from_both_shapes(case): } assert _legacy_maps(v2_response) == expected - assert _legacy_maps(v3_response) == expected + assert _legacy_maps(v4_response) == expected VALUE_SUCCESS_CASES = [ @@ -642,9 +642,9 @@ def _value_case_records(case: Dict[str, Any]) -> tuple[Dict[str, Any], Dict[str, "description": "Evaluated", } metadata = {"id": 401, "version": 3, "description": None, "has_experiment": False} - v3_metadata = {**metadata, "config_version": 2, "payload": None} + v4_metadata = {**metadata, "config_version": 2, "payload": None} if rule: - v3_metadata.update(rule_type=rule["rule_type"], rule_id=rule["id"]) + v4_metadata.update(rule_type=rule["rule_type"], rule_id=rule["id"]) payload = legacy["payload"] v2_record = { "key": "typed-flag", @@ -656,18 +656,18 @@ def _value_case_records(case: Dict[str, Any]) -> tuple[Dict[str, Any], Dict[str, "payload": None if payload is None else json.dumps(payload), }, } - v3_record = { + v4_record = { "key": "typed-flag", "value": expected["value"], "reason": reason, - "metadata": v3_metadata, + "metadata": v4_metadata, } - return v2_record, v3_record + return v2_record, v4_record @pytest.mark.parametrize("case", VALUE_SUCCESS_CASES, ids=lambda case: case["id"]) def test_typed_values_render_the_corpus_legacy_cells_from_both_shapes(case): - v2_record, v3_record = _value_case_records(case) + v2_record, v4_record = _value_case_records(case) legacy = case["legacy"] expected = { "featureFlags": {"typed-flag": legacy["value"]}, @@ -676,7 +676,7 @@ def test_typed_values_render_the_corpus_legacy_cells_from_both_shapes(case): else {"typed-flag": legacy["payload"]}, } - for record in (v2_record, v3_record): + for record in (v2_record, v4_record): maps = _legacy_maps({"flags": {"typed-flag": record}}) assert maps == expected assert type(maps["featureFlags"]["typed-flag"]) is type(legacy["value"]) @@ -702,11 +702,11 @@ def test_number_and_object_payloads_are_compact_json_in_wire_order(): assert payloads == {"number": "12.5", "object": '{"b":"ü","a":[1,true,null]}'} -# Every legacy getter and event, end to end: a v2-shaped and a v3-shaped response for +# Every legacy getter and event, end to end: a v2-shaped and a v4-shaped response for # the same flags give identical results. PARITY_FLAGS = [ - # key, v2 record fields, v3 value + # key, v2 record fields, v4 value ("bool-on", {"enabled": True, "variant": None}, True), ("bool-off", {"enabled": False, "variant": None}, False), ("v1-variant", {"enabled": True, "variant": "control"}, "control"), @@ -836,11 +836,11 @@ def enqueue(message: Dict[str, Any], *args: Any, **kwargs: Any) -> Optional[str] def test_every_legacy_getter_and_event_is_identical_for_both_shapes(): v2_outputs = _legacy_outputs("v2") - v3_outputs = _legacy_outputs("v3") + v4_outputs = _legacy_outputs("v4") - assert v3_outputs == v2_outputs + assert v4_outputs == v2_outputs # Spot-check the legacy rendering itself. - assert v3_outputs["get_all_flags"] == { + assert v4_outputs["get_all_flags"] == { "bool-on": True, "bool-off": False, "v1-variant": "control", @@ -849,8 +849,8 @@ def test_every_legacy_getter_and_event_is_identical_for_both_shapes(): "v2-number": True, "v2-object": True, } - assert v3_outputs["get_feature_flag_payload:v2-object"] == {"layout": "compact"} - assert v3_outputs["snapshot.get_flag_payload:v2-number"] == 12.5 + assert v4_outputs["get_feature_flag_payload:v2-object"] == {"layout": "compact"} + assert v4_outputs["snapshot.get_flag_payload:v2-number"] == 12.5 def test_feature_flag_called_properties_are_unchanged_for_a_v1_flag(client): @@ -899,7 +899,7 @@ def test_typed_accessors_report_access_like_get_flag(client): assert snapshot.only_accessed().keys == ["string-flag"] -async def test_async_client_reads_the_v3_response(): +async def test_async_client_reads_the_v4_response(): with mock.patch( "posthog.async_client._async_flags", new=mock.AsyncMock(return_value=_typed_response(TYPED_VALUES)), diff --git a/posthog/types.py b/posthog/types.py index 78626af38..8ea9220a6 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -153,7 +153,7 @@ class FlagMetadata: id: Numeric feature flag ID. payload: Payload configured for the matched flag value, if any. For a number or object value it is the value encoded as JSON, the way - servers before ``/flags?v=3`` sent it. + servers before ``/flags?v=4`` sent it. version: Feature flag version. description: Feature flag description. has_experiment: Whether the flag has a linked experiment. ``None`` when @@ -182,7 +182,7 @@ class FlagMetadata: def from_json(cls, resp: Any) -> Union["FlagMetadata", LegacyFlagMetadata]: """Parse the metadata of a record without a typed ``value``. - Such a record comes from a server before ``/flags?v=3``, so only the + Such a record comes from a server before ``/flags?v=4``, so only the fields that response carries are read, and ``config_version`` is ``1``. """ if not resp: @@ -241,7 +241,7 @@ class FeatureFlag: ``value``, ``failed``, ``reason`` and ``metadata`` are the evaluation details. ``enabled``, ``variant`` and ``metadata.payload`` are the value rendered the way - responses before ``/flags?v=3`` carried it, which the legacy getters return. + responses before ``/flags?v=4`` carried it, which the legacy getters return. Attributes: key: Feature flag key. @@ -283,7 +283,7 @@ def get_value(self) -> FlagValue: def from_json(cls, resp: Any) -> "FeatureFlag": """Parse one flag record of a ``/flags`` response. - A record with a ``value`` member is a ``/flags?v=3`` record. A record + A record with a ``value`` member is a ``/flags?v=4`` record. A record without one comes from an older server: its value is ``variant``, or ``enabled`` when there is no variant, and its config version is ``1``. """ @@ -377,7 +377,7 @@ def _legacy_rendering(value: Any) -> tuple[bool, Optional[str], Optional[str]]: """Render a typed value as ``(enabled, variant, encoded payload)``. A string is the variant, ``None`` is disabled, and a number or object is enabled - with the value as a JSON-encoded payload, as servers before ``/flags?v=3`` sent it. + with the value as a JSON-encoded payload, as servers before ``/flags?v=4`` sent it. """ if value is None or isinstance(value, bool): return bool(value), None, None @@ -387,7 +387,7 @@ def _legacy_rendering(value: Any) -> tuple[bool, Optional[str], Optional[str]]: def _parse_typed_record(key: str, resp: Any) -> FeatureFlag: - """Parse a ``/flags?v=3`` record, which carries a typed ``value``. + """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, with ``PARSE_ERROR``. An absent or null field reads as absent, @@ -683,7 +683,7 @@ def normalize_flags_response(resp: Any) -> FlagsResponse: Normalize the response from the flags API endpoint into a FlagsResponse. Args: - resp: A v1, v2 or v3 response from the flags API endpoint. + resp: A v1, v2 or v4 response from the flags API endpoint. Returns: A FlagsResponse containing feature flags and their details. From a5d7c7e289d61f4a5a7e8e0ce2172687ed3f75a8 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Wed, 28 Oct 2026 13:35:32 +0100 Subject: [PATCH 05/35] feat(flags): match typed flag details to the SDK spec and contract 7.0.0 Align the evaluate_flags() typed accessors with the typed flag values API proposed in PostHog/sdk-specs#104. - FlagEvaluationDetails has an OpenFeature reason (FlagEvaluationReason) and a flag_metadata dict of the record's id, version, config_version, reason_code, condition_index, rule_type, rule_id and variant_key. It no longer carries the structured reason or the metadata object, so the payload stays on the payload accessors. - variant is set only when the accessor returns the flag's value. - Locally evaluated flags read with config_version 1 and a reason derived from the value, or DISABLED for an inactive flag. - Each client logs once when /flags sends records without typed values, and a TYPE_MISMATCH message says why the value was not returned. - Add get_integer_value/details and get_float_value/details. Object values are returned as copies. - Re-vendor the rules v2 contract fixtures from posthog-sdk-test-harness 1.22.0 (contract 7.0.0). A config version 1 record may carry an integer metadata.experiment_id, so its type is checked; it is not kept. --- .sampo/changesets/flags-v4-response.md | 2 +- posthog/__init__.py | 1 + posthog/async_client.py | 15 +- posthog/client.py | 10 + posthog/feature_flag_evaluations.py | 103 +- .../feature_flag_rules_v2/SOURCE.json | 11 +- .../feature_flag_rules_v2/readers.json | 67 +- .../feature_flag_rules_v2/responses.json | 1340 ++--------------- posthog/test/test_flags_v4_response.py | 392 ++++- posthog/types.py | 290 +++- references/public_api_snapshot.txt | 19 +- 11 files changed, 853 insertions(+), 1397 deletions(-) diff --git a/.sampo/changesets/flags-v4-response.md b/.sampo/changesets/flags-v4-response.md index beacb6bc3..976dc5397 100644 --- a/.sampo/changesets/flags-v4-response.md +++ b/.sampo/changesets/flags-v4-response.md @@ -2,4 +2,4 @@ pypi/posthog: minor --- -Request `/flags?v=4` and keep each flag's typed value, reason and metadata. Servers that still send the older response keep working. Existing getters, callbacks and `$feature_flag_called` properties return the same values as before: a number or object value reads as `True`, with the value as its payload. Add `get_boolean_value`, `get_string_value`, `get_number_value` and `get_object_value` to the `evaluate_flags()` result, each with a `_details` form that also returns the reason and 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. +Request `/flags?v=4` and keep each flag's typed value, reason and metadata. Servers that still send the older response keep working. Existing getters, callbacks and `$feature_flag_called` properties return the same values as before: a number or object value reads as `True`, with the value as its payload. 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 4a94907ed..eda01933c 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -83,6 +83,7 @@ FeatureFlagEvaluationRuntime as FeatureFlagEvaluationRuntime, FlagEvaluationDetails as FlagEvaluationDetails, FlagEvaluationErrorCode as FlagEvaluationErrorCode, + FlagEvaluationReason as FlagEvaluationReason, FlagValue as FlagValue, FlagsAndPayloads as FlagsAndPayloads, ) diff --git a/posthog/async_client.py b/posthog/async_client.py index 3a346b255..e161cbd53 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -77,7 +77,13 @@ ) from .release_id import _resolve_release_id from .request import QuotaLimitError, determine_server_host, normalize_host -from .types import FlagMetadata, FlagValue, normalize_flags_response +from .types import ( + FlagMetadata, + FlagValue, + _has_untyped_flag_records, + _UNTYPED_FLAGS_RESPONSE_MESSAGE, + normalize_flags_response, +) from .utils import SizeLimitedDict, _normalize_timestamp, clean, system_context from .version import VERSION @@ -223,6 +229,8 @@ def __init__( self.log.setLevel(logging.DEBUG) else: self.log.setLevel(logging.WARNING) + # Whether this client has logged that /flags sent records without typed values. + self._logged_untyped_flags_response = False if not self.api_key: self.log.error( @@ -876,6 +884,11 @@ async def _get_flags_decision( client=self._get_http_client(), **request_data, ) + if not self._logged_untyped_flags_response and _has_untyped_flag_records( + response + ): + self._logged_untyped_flags_response = True + self.log.info(_UNTYPED_FLAGS_RESPONSE_MESSAGE) return normalize_flags_response(response) async def evaluate_flags( diff --git a/posthog/client.py b/posthog/client.py index b821e8b0a..c69ce2275 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -115,6 +115,8 @@ FlagsResponse, FlagValue, SendFeatureFlagsOptions, + _has_untyped_flag_records, + _UNTYPED_FLAGS_RESPONSE_MESSAGE, normalize_flags_response, to_flags_and_payloads, to_payloads, @@ -985,6 +987,8 @@ def __init__( # the /flags v2 response and the local-evaluation payload. False until the # server reports it, so full events are the fail-safe. 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 self.capture_trace_context = capture_trace_context self.capture_exception_code_variables = capture_exception_code_variables @@ -1565,6 +1569,11 @@ def _get_flags_decision( **request_data, ) + if not self._logged_untyped_flags_response and _has_untyped_flag_records( + resp_data + ): + self._logged_untyped_flags_response = True + self.log.info(_UNTYPED_FLAGS_RESPONSE_MESSAGE) response = normalize_flags_response(resp_data) # Server-controlled gate for minimal $feature_flag_called events. Only the # v2 response shape carries it; absent (legacy shape, older server, team @@ -4644,6 +4653,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 7e2884cd9..c9a6f808c 100644 --- a/posthog/feature_flag_evaluations.py +++ b/posthog/feature_flag_evaluations.py @@ -13,6 +13,7 @@ FlagEvaluationDetails, FlagValue, _resolve_flag_details, + _resolve_flag_value, ) @@ -34,6 +35,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 @@ -144,10 +147,12 @@ def get_boolean_details( ) -> FlagEvaluationDetails[bool]: """Return the flag's boolean 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. Fires - ``$feature_flag_called`` on first access, like :meth:`get_flag`. + 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") @@ -186,14 +191,51 @@ def get_number_details( ) -> FlagEvaluationDetails[float]: """Return the flag's number value with its evaluation details. - The value is never coerced: a boolean 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`. + 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]: @@ -209,11 +251,12 @@ def get_object_details( ) -> 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. - ``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`. + 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") @@ -226,8 +269,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. @@ -314,12 +357,30 @@ def _get_typed_details( ) -> FlagEvaluationDetails[Any]: flag = self._flags.get(key) self._record_access(key) - details = None - if flag is not None: - details = flag.details or FeatureFlag.from_value_and_payload( - key, flag.variant if flag.variant is not None else flag.enabled, None + 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 ) - return _resolve_flag_details(key, details, 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/fixtures/feature_flag_rules_v2/SOURCE.json b/posthog/test/fixtures/feature_flag_rules_v2/SOURCE.json index 0eee1fe70..df7f1c7e0 100644 --- a/posthog/test/fixtures/feature_flag_rules_v2/SOURCE.json +++ b/posthog/test/fixtures/feature_flag_rules_v2/SOURCE.json @@ -1,19 +1,18 @@ { "description": "Copied unchanged from the released feature flag rules v2 contract in PostHog/posthog-sdk-test-harness. Do not edit these files; copy a newer release instead.", "repository": "https://github.com/PostHog/posthog-sdk-test-harness", - "release": "1.13.1", - "revision": "e32f08bd30fab57ce543da5808a668d0fda697b0", + "release": "1.22.0", + "revision": "17d861f8e230fe943934d70e3eebf5cff6bf3aa4", "contract_directory": "contracts/feature_flag_rules_v2", - "contract_version": "2.3.1", - "checksum_index_sha256": "8fbf9ae84ae29ee0b09150a9c8cc378942c716a287a202d6a057131467407c6e", + "contract_version": "7.0.0", "files": { "readers.json": { "source_path": "fixtures/wire/readers.json", - "sha256": "07499c5a7ca7467968156ee16fa95bdeb9018c0b841c4ec8f5bffc61d72de4fa" + "sha256": "fd8edd89f827b732c4659ce7246eab534b0106b96a465a4a2d98cfa0a17d7827" }, "responses.json": { "source_path": "fixtures/wire/responses.json", - "sha256": "e34659b62facbc594bdc1ce6c0f127ff25d94672da332670b383d729f45e5960" + "sha256": "39aa62e340d66903e2fe86da226e622979c7dbc2efbf2b32afd040248a707c61" }, "legacy_projection.json": { "source_path": "corpus/legacy_projection.json", diff --git a/posthog/test/fixtures/feature_flag_rules_v2/readers.json b/posthog/test/fixtures/feature_flag_rules_v2/readers.json index 1e24b8e23..7ed8c252b 100644 --- a/posthog/test/fixtures/feature_flag_rules_v2/readers.json +++ b/posthog/test/fixtures/feature_flag_rules_v2/readers.json @@ -1,6 +1,5 @@ { - "fixture_version": "1.0.0", - "schema": "schemas/flags_response_v3.schema.json", + "schema": "schemas/flags_response_v4.schema.json", "templates": { "targeting_match_targeted_release": { "errorsWhileComputingFlags": false, @@ -68,7 +67,6 @@ "payload": null, "rule_type": "experiment", "rule_id": "10000000-0000-4000-8000-000000000001", - "experiment_id": 301, "variant_key": "arm_a" } } @@ -117,8 +115,7 @@ "has_experiment": true, "payload": null, "rule_type": "experiment", - "rule_id": "10000000-0000-4000-8000-000000000001", - "experiment_id": 301 + "rule_id": "10000000-0000-4000-8000-000000000001" } } }, @@ -142,8 +139,7 @@ "has_experiment": true, "payload": null, "rule_type": "experiment", - "rule_id": "10000000-0000-4000-8000-000000000001", - "experiment_id": 301 + "rule_id": "10000000-0000-4000-8000-000000000001" } } }, @@ -167,9 +163,7 @@ "has_experiment": true, "payload": null, "rule_type": "experiment", - "rule_id": "10000000-0000-4000-8000-000000000001", - "experiment_id": 301, - "holdout_id": 401 + "rule_id": "10000000-0000-4000-8000-000000000001" } } }, @@ -312,7 +306,6 @@ "config_version": 2, "rule_type": "experiment", "rule_id": "10000000-0000-4000-8000-000000000001", - "experiment_id": 301, "variant_key": "arm_a" } } @@ -405,29 +398,6 @@ }, "producer_expected": "invalid" }, - { - "id": "readers.partial_split_missing_experiment_id", - "template": "experiment_split", - "remove": [ - "/flags/garden-layout/metadata/experiment_id" - ], - "expected": "reader", - "reader": { - "requested_key": "garden-layout", - "caller_default": true, - "value": false, - "config_version": 2, - "experiment_exposure": false, - "error_code": null - }, - "producer_failure": { - "layer": "schema", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "experiment_id" - }, - "producer_expected": "invalid" - }, { "id": "readers.partial_split_missing_variant_key", "template": "experiment_split", @@ -539,6 +509,28 @@ "instance_path": "/flags/garden-layout/metadata/experiment_id" } }, + { + "id": "readers.split_extra_experiment_id", + "template": "experiment_split", + "set": { + "/flags/garden-layout/metadata/experiment_id": 301 + }, + "expected": "reader", + "reader": { + "requested_key": "garden-layout", + "caller_default": true, + "value": false, + "config_version": 2, + "experiment_exposure": true, + "error_code": null + }, + "producer_expected": "invalid", + "producer_failure": { + "layer": "schema", + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" + } + }, { "id": "readers.split_wrong_type_variant_key", "template": "experiment_split", @@ -703,7 +695,7 @@ } }, { - "id": "readers.v3_value_takes_precedence", + "id": "readers.v4_value_takes_precedence", "template": "experiment_split", "set": { "/flags/garden-layout/enabled": true, @@ -991,7 +983,7 @@ } }, { - "id": "readers.v1_at_v3", + "id": "readers.v1_at_v4", "template": "v1", "expected": "reader", "reader": { @@ -1035,7 +1027,7 @@ "template": "experiment_split", "set": { "/flags/garden-layout/metadata/config_version": 2.0, - "/flags/garden-layout/metadata/experiment_id": 301.0 + "/flags/garden-layout/metadata/id": 201.0 }, "expected": "reader", "reader": { @@ -1067,7 +1059,6 @@ "payload": null, "rule_type": "experiment", "rule_id": "10000000-0000-4000-8000-000000000001", - "experiment_id": 301, "variant_key": "arm_a" } } diff --git a/posthog/test/fixtures/feature_flag_rules_v2/responses.json b/posthog/test/fixtures/feature_flag_rules_v2/responses.json index 959dc51be..1d1c60969 100644 --- a/posthog/test/fixtures/feature_flag_rules_v2/responses.json +++ b/posthog/test/fixtures/feature_flag_rules_v2/responses.json @@ -1,6 +1,5 @@ { - "fixture_version": "1.0.0", - "schema": "schemas/flags_response_v3.schema.json", + "schema": "schemas/flags_response_v4.schema.json", "templates": { "targeting_match_targeted_release": { "errorsWhileComputingFlags": false, @@ -68,7 +67,6 @@ "payload": null, "rule_type": "experiment", "rule_id": "10000000-0000-4000-8000-000000000001", - "experiment_id": 301, "variant_key": "arm_a" } } @@ -117,8 +115,7 @@ "has_experiment": true, "payload": null, "rule_type": "experiment", - "rule_id": "10000000-0000-4000-8000-000000000001", - "experiment_id": 301 + "rule_id": "10000000-0000-4000-8000-000000000001" } } }, @@ -142,8 +139,7 @@ "has_experiment": true, "payload": null, "rule_type": "experiment", - "rule_id": "10000000-0000-4000-8000-000000000001", - "experiment_id": 301 + "rule_id": "10000000-0000-4000-8000-000000000001" } } }, @@ -167,9 +163,7 @@ "has_experiment": true, "payload": null, "rule_type": "experiment", - "rule_id": "10000000-0000-4000-8000-000000000001", - "experiment_id": 301, - "holdout_id": 401 + "rule_id": "10000000-0000-4000-8000-000000000001" } } }, @@ -331,1051 +325,22 @@ } }, { - "id": "responses.missing_config_version", - "template": "targeting_match_targeted_release", - "remove": [ - "/flags/garden-layout/metadata/config_version" - ], - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "config_version" - } - }, - { - "id": "responses.targeting_match_targeted_release_missing_has_experiment", - "template": "targeting_match_targeted_release", - "remove": [ - "/flags/garden-layout/metadata/has_experiment" - ], - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "has_experiment" - } - }, - { - "id": "responses.targeting_match_targeted_release_missing_rule_type", - "template": "targeting_match_targeted_release", - "remove": [ - "/flags/garden-layout/metadata/rule_type" - ], - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "rule_type" - } - }, - { - "id": "responses.targeting_match_targeted_release_missing_rule_id", - "template": "targeting_match_targeted_release", - "remove": [ - "/flags/garden-layout/metadata/rule_id" - ], - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "rule_id" - } - }, - { - "id": "responses.targeting_match_targeted_release_forbidden_experiment_id", - "template": "targeting_match_targeted_release", - "set": { - "/flags/garden-layout/metadata/experiment_id": 301 - }, - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.targeting_match_targeted_release_forbidden_variant_key", - "template": "targeting_match_targeted_release", - "set": { - "/flags/garden-layout/metadata/variant_key": "arm_a" - }, - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.targeting_match_targeted_release_forbidden_holdout_id", - "template": "targeting_match_targeted_release", - "set": { - "/flags/garden-layout/metadata/holdout_id": 401 - }, - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.targeting_match_targeted_release_wrong_condition_index", - "template": "targeting_match_targeted_release", - "set": { - "/flags/garden-layout/reason/condition_index": null - }, - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "type", - "instance_path": "/flags/garden-layout/reason/condition_index" - } - }, - { - "id": "responses.targeting_match_targeted_release_forbidden_failed", - "template": "targeting_match_targeted_release", - "set": { - "/flags/garden-layout/failed": true, - "/flags/garden-layout/value": null, - "/errorsWhileComputingFlags": true - }, - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.targeting_match_targeted_release_flag_with_experiment", - "template": "targeting_match_targeted_release", - "set": { - "/flags/garden-layout/metadata/has_experiment": true - }, - "expected": "valid" - }, - { - "id": "responses.targeting_match_percentage_rollout", - "template": "targeting_match_percentage_rollout", - "expected": "valid" - }, - { - "id": "responses.targeting_match_percentage_rollout_missing_has_experiment", - "template": "targeting_match_percentage_rollout", - "remove": [ - "/flags/garden-layout/metadata/has_experiment" - ], - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "has_experiment" - } - }, - { - "id": "responses.targeting_match_percentage_rollout_missing_rule_type", - "template": "targeting_match_percentage_rollout", - "remove": [ - "/flags/garden-layout/metadata/rule_type" - ], - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "rule_type" - } - }, - { - "id": "responses.targeting_match_percentage_rollout_missing_rule_id", - "template": "targeting_match_percentage_rollout", - "remove": [ - "/flags/garden-layout/metadata/rule_id" - ], - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "rule_id" - } - }, - { - "id": "responses.targeting_match_percentage_rollout_forbidden_experiment_id", - "template": "targeting_match_percentage_rollout", - "set": { - "/flags/garden-layout/metadata/experiment_id": 301 - }, - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.targeting_match_percentage_rollout_forbidden_variant_key", - "template": "targeting_match_percentage_rollout", - "set": { - "/flags/garden-layout/metadata/variant_key": "arm_a" - }, - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.targeting_match_percentage_rollout_forbidden_holdout_id", - "template": "targeting_match_percentage_rollout", - "set": { - "/flags/garden-layout/metadata/holdout_id": 401 - }, - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.targeting_match_percentage_rollout_wrong_condition_index", - "template": "targeting_match_percentage_rollout", - "set": { - "/flags/garden-layout/reason/condition_index": null - }, - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "type", - "instance_path": "/flags/garden-layout/reason/condition_index" - } - }, - { - "id": "responses.targeting_match_percentage_rollout_forbidden_failed", - "template": "targeting_match_percentage_rollout", - "set": { - "/flags/garden-layout/failed": true, - "/flags/garden-layout/value": null, - "/errorsWhileComputingFlags": true - }, - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.experiment_split", - "template": "experiment_split", - "expected": "valid" - }, - { - "id": "responses.experiment_split_missing_has_experiment", - "template": "experiment_split", - "remove": [ - "/flags/garden-layout/metadata/has_experiment" - ], - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "has_experiment" - } - }, - { - "id": "responses.experiment_split_missing_rule_type", - "template": "experiment_split", - "remove": [ - "/flags/garden-layout/metadata/rule_type" - ], - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "rule_type" - } - }, - { - "id": "responses.experiment_split_missing_rule_id", - "template": "experiment_split", - "remove": [ - "/flags/garden-layout/metadata/rule_id" - ], - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "rule_id" - } - }, - { - "id": "responses.experiment_split_missing_experiment_id", - "template": "experiment_split", - "remove": [ - "/flags/garden-layout/metadata/experiment_id" - ], - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "experiment_id" - } - }, - { - "id": "responses.experiment_split_missing_variant_key", - "template": "experiment_split", - "remove": [ - "/flags/garden-layout/metadata/variant_key" - ], - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "variant_key" - } - }, - { - "id": "responses.experiment_split_forbidden_holdout_id", - "template": "experiment_split", - "set": { - "/flags/garden-layout/metadata/holdout_id": 401 - }, - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.experiment_split_wrong_condition_index", - "template": "experiment_split", - "set": { - "/flags/garden-layout/reason/condition_index": null - }, - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "type", - "instance_path": "/flags/garden-layout/reason/condition_index" - } - }, - { - "id": "responses.experiment_split_forbidden_failed", - "template": "experiment_split", - "set": { - "/flags/garden-layout/failed": true, - "/flags/garden-layout/value": null, - "/errorsWhileComputingFlags": true - }, - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.experiment_split_contradicted_has_experiment", - "template": "experiment_split", - "set": { - "/flags/garden-layout/metadata/has_experiment": false - }, - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "const", - "instance_path": "/flags/garden-layout/metadata/has_experiment" - } - }, - { - "id": "responses.rollout_miss_percentage_rollout", - "template": "rollout_miss_percentage_rollout", - "expected": "valid" - }, - { - "id": "responses.rollout_miss_percentage_rollout_missing_has_experiment", - "template": "rollout_miss_percentage_rollout", - "remove": [ - "/flags/garden-layout/metadata/has_experiment" - ], - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "has_experiment" - } - }, - { - "id": "responses.rollout_miss_percentage_rollout_missing_rule_type", - "template": "rollout_miss_percentage_rollout", - "remove": [ - "/flags/garden-layout/metadata/rule_type" - ], - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "rule_type" - } - }, - { - "id": "responses.rollout_miss_percentage_rollout_missing_rule_id", - "template": "rollout_miss_percentage_rollout", - "remove": [ - "/flags/garden-layout/metadata/rule_id" - ], - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "rule_id" - } - }, - { - "id": "responses.rollout_miss_percentage_rollout_forbidden_experiment_id", - "template": "rollout_miss_percentage_rollout", - "set": { - "/flags/garden-layout/metadata/experiment_id": 301 - }, - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.rollout_miss_percentage_rollout_forbidden_variant_key", - "template": "rollout_miss_percentage_rollout", - "set": { - "/flags/garden-layout/metadata/variant_key": "arm_a" - }, - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.rollout_miss_percentage_rollout_forbidden_holdout_id", - "template": "rollout_miss_percentage_rollout", - "set": { - "/flags/garden-layout/metadata/holdout_id": 401 - }, - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.rollout_miss_percentage_rollout_wrong_condition_index", - "template": "rollout_miss_percentage_rollout", - "set": { - "/flags/garden-layout/reason/condition_index": null - }, - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "type", - "instance_path": "/flags/garden-layout/reason/condition_index" - } - }, - { - "id": "responses.rollout_miss_percentage_rollout_forbidden_failed", - "template": "rollout_miss_percentage_rollout", - "set": { - "/flags/garden-layout/failed": true, - "/flags/garden-layout/value": null, - "/errorsWhileComputingFlags": true - }, - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.rollout_miss_experiment", - "template": "rollout_miss_experiment", - "expected": "valid" - }, - { - "id": "responses.rollout_miss_experiment_missing_has_experiment", - "template": "rollout_miss_experiment", - "remove": [ - "/flags/garden-layout/metadata/has_experiment" - ], - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "has_experiment" - } - }, - { - "id": "responses.rollout_miss_experiment_missing_rule_type", - "template": "rollout_miss_experiment", - "remove": [ - "/flags/garden-layout/metadata/rule_type" - ], - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "rule_type" - } - }, - { - "id": "responses.rollout_miss_experiment_missing_rule_id", - "template": "rollout_miss_experiment", - "remove": [ - "/flags/garden-layout/metadata/rule_id" - ], - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "rule_id" - } - }, - { - "id": "responses.rollout_miss_experiment_missing_experiment_id", - "template": "rollout_miss_experiment", - "remove": [ - "/flags/garden-layout/metadata/experiment_id" - ], - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "experiment_id" - } - }, - { - "id": "responses.rollout_miss_experiment_forbidden_variant_key", - "template": "rollout_miss_experiment", - "set": { - "/flags/garden-layout/metadata/variant_key": "arm_a" - }, - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.rollout_miss_experiment_forbidden_holdout_id", - "template": "rollout_miss_experiment", - "set": { - "/flags/garden-layout/metadata/holdout_id": 401 - }, - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.rollout_miss_experiment_wrong_condition_index", - "template": "rollout_miss_experiment", - "set": { - "/flags/garden-layout/reason/condition_index": null - }, - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "type", - "instance_path": "/flags/garden-layout/reason/condition_index" - } - }, - { - "id": "responses.rollout_miss_experiment_forbidden_failed", - "template": "rollout_miss_experiment", - "set": { - "/flags/garden-layout/failed": true, - "/flags/garden-layout/value": null, - "/errorsWhileComputingFlags": true - }, - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.experiment_paused", - "template": "experiment_paused", - "expected": "valid" - }, - { - "id": "responses.experiment_paused_missing_has_experiment", - "template": "experiment_paused", - "remove": [ - "/flags/garden-layout/metadata/has_experiment" - ], - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "has_experiment" - } - }, - { - "id": "responses.experiment_paused_missing_rule_type", - "template": "experiment_paused", - "remove": [ - "/flags/garden-layout/metadata/rule_type" - ], - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "rule_type" - } - }, - { - "id": "responses.experiment_paused_missing_rule_id", - "template": "experiment_paused", - "remove": [ - "/flags/garden-layout/metadata/rule_id" - ], - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "rule_id" - } - }, - { - "id": "responses.experiment_paused_missing_experiment_id", - "template": "experiment_paused", - "remove": [ - "/flags/garden-layout/metadata/experiment_id" - ], - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "experiment_id" - } - }, - { - "id": "responses.experiment_paused_forbidden_variant_key", - "template": "experiment_paused", - "set": { - "/flags/garden-layout/metadata/variant_key": "arm_a" - }, - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.experiment_paused_forbidden_holdout_id", - "template": "experiment_paused", - "set": { - "/flags/garden-layout/metadata/holdout_id": 401 - }, - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.experiment_paused_wrong_condition_index", - "template": "experiment_paused", - "set": { - "/flags/garden-layout/reason/condition_index": null - }, - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "type", - "instance_path": "/flags/garden-layout/reason/condition_index" - } - }, - { - "id": "responses.experiment_paused_forbidden_failed", - "template": "experiment_paused", - "set": { - "/flags/garden-layout/failed": true, - "/flags/garden-layout/value": null, - "/errorsWhileComputingFlags": true - }, - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.holdout", - "template": "holdout", - "expected": "valid" - }, - { - "id": "responses.holdout_missing_has_experiment", - "template": "holdout", - "remove": [ - "/flags/garden-layout/metadata/has_experiment" - ], - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "has_experiment" - } - }, - { - "id": "responses.holdout_missing_rule_type", - "template": "holdout", - "remove": [ - "/flags/garden-layout/metadata/rule_type" - ], - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "rule_type" - } - }, - { - "id": "responses.holdout_missing_rule_id", - "template": "holdout", - "remove": [ - "/flags/garden-layout/metadata/rule_id" - ], - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "rule_id" - } - }, - { - "id": "responses.holdout_missing_experiment_id", - "template": "holdout", - "remove": [ - "/flags/garden-layout/metadata/experiment_id" - ], - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "experiment_id" - } - }, - { - "id": "responses.holdout_missing_holdout_id", - "template": "holdout", - "remove": [ - "/flags/garden-layout/metadata/holdout_id" - ], - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "holdout_id" - } - }, - { - "id": "responses.holdout_forbidden_variant_key", - "template": "holdout", - "set": { - "/flags/garden-layout/metadata/variant_key": "arm_a" - }, - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.holdout_wrong_condition_index", - "template": "holdout", - "set": { - "/flags/garden-layout/reason/condition_index": null - }, - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "type", - "instance_path": "/flags/garden-layout/reason/condition_index" - } - }, - { - "id": "responses.holdout_forbidden_failed", - "template": "holdout", - "set": { - "/flags/garden-layout/failed": true, - "/flags/garden-layout/value": null, - "/errorsWhileComputingFlags": true - }, - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.no_rule_match", - "template": "no_rule_match", - "expected": "valid" - }, - { - "id": "responses.no_rule_match_missing_has_experiment", - "template": "no_rule_match", - "remove": [ - "/flags/garden-layout/metadata/has_experiment" - ], - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "has_experiment" - } - }, - { - "id": "responses.no_rule_match_forbidden_rule_type", - "template": "no_rule_match", - "set": { - "/flags/garden-layout/metadata/rule_type": "experiment" - }, - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.no_rule_match_forbidden_rule_id", - "template": "no_rule_match", - "set": { - "/flags/garden-layout/metadata/rule_id": "10000000-0000-4000-8000-000000000001" - }, - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.no_rule_match_forbidden_experiment_id", - "template": "no_rule_match", - "set": { - "/flags/garden-layout/metadata/experiment_id": 301 - }, - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.no_rule_match_forbidden_variant_key", - "template": "no_rule_match", - "set": { - "/flags/garden-layout/metadata/variant_key": "arm_a" - }, - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.no_rule_match_forbidden_holdout_id", - "template": "no_rule_match", - "set": { - "/flags/garden-layout/metadata/holdout_id": 401 - }, - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.no_rule_match_wrong_condition_index", - "template": "no_rule_match", - "set": { - "/flags/garden-layout/reason/condition_index": 0 - }, - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "type", - "instance_path": "/flags/garden-layout/reason/condition_index" - } - }, - { - "id": "responses.no_rule_match_forbidden_failed", - "template": "no_rule_match", - "set": { - "/flags/garden-layout/failed": true, - "/flags/garden-layout/value": null, - "/errorsWhileComputingFlags": true - }, - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "not", - "instance_path": "/flags/garden-layout" - } - }, - { - "id": "responses.missing_group_key", - "template": "missing_group_key", - "expected": "valid" - }, - { - "id": "responses.missing_group_key_missing_has_experiment", - "template": "missing_group_key", - "remove": [ - "/flags/garden-layout/metadata/has_experiment" - ], - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "has_experiment" - } - }, - { - "id": "responses.missing_group_key_forbidden_rule_type", - "template": "missing_group_key", - "set": { - "/flags/garden-layout/metadata/rule_type": "experiment" - }, - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.missing_group_key_forbidden_rule_id", - "template": "missing_group_key", - "set": { - "/flags/garden-layout/metadata/rule_id": "10000000-0000-4000-8000-000000000001" - }, - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.missing_group_key_forbidden_experiment_id", - "template": "missing_group_key", - "set": { - "/flags/garden-layout/metadata/experiment_id": 301 - }, - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.missing_group_key_forbidden_variant_key", - "template": "missing_group_key", - "set": { - "/flags/garden-layout/metadata/variant_key": "arm_a" - }, - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.missing_group_key_forbidden_holdout_id", - "template": "missing_group_key", - "set": { - "/flags/garden-layout/metadata/holdout_id": 401 - }, - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, - { - "id": "responses.missing_group_key_wrong_condition_index", - "template": "missing_group_key", - "set": { - "/flags/garden-layout/reason/condition_index": 0 - }, + "id": "responses.missing_config_version", + "template": "targeting_match_targeted_release", + "remove": [ + "/flags/garden-layout/metadata/config_version" + ], "expected": "invalid", "expected_failure": { "layer": "schema", - "keyword": "type", - "instance_path": "/flags/garden-layout/reason/condition_index" + "keyword": "required", + "instance_path": "/flags/garden-layout/metadata", + "message_contains": "config_version" } }, { - "id": "responses.missing_group_key_forbidden_failed", - "template": "missing_group_key", + "id": "responses.targeting_match_targeted_release_forbidden_failed", + "template": "targeting_match_targeted_release", "set": { "/flags/garden-layout/failed": true, "/flags/garden-layout/value": null, @@ -1383,35 +348,31 @@ }, "expected": "invalid", "expected_failure": { - "layer": "presence", + "layer": "schema", "keyword": "not", - "instance_path": "/flags/garden-layout" + "instance_path": "/flags/garden-layout/metadata" } }, { - "id": "responses.dependency_error", - "template": "dependency_error", + "id": "responses.targeting_match_targeted_release_flag_with_experiment", + "template": "targeting_match_targeted_release", + "set": { + "/flags/garden-layout/metadata/has_experiment": true + }, "expected": "valid" }, { - "id": "responses.dependency_error_missing_has_experiment", - "template": "dependency_error", - "remove": [ - "/flags/garden-layout/metadata/has_experiment" - ], - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "required", - "instance_path": "/flags/garden-layout/metadata", - "message_contains": "has_experiment" - } + "id": "responses.targeting_match_percentage_rollout", + "template": "targeting_match_percentage_rollout", + "expected": "valid" }, { - "id": "responses.dependency_error_forbidden_rule_type", - "template": "dependency_error", + "id": "responses.targeting_match_percentage_rollout_forbidden_failed", + "template": "targeting_match_percentage_rollout", "set": { - "/flags/garden-layout/metadata/rule_type": "experiment" + "/flags/garden-layout/failed": true, + "/flags/garden-layout/value": null, + "/errorsWhileComputingFlags": true }, "expected": "invalid", "expected_failure": { @@ -1421,23 +382,17 @@ } }, { - "id": "responses.dependency_error_forbidden_rule_id", - "template": "dependency_error", - "set": { - "/flags/garden-layout/metadata/rule_id": "10000000-0000-4000-8000-000000000001" - }, - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } + "id": "responses.experiment_split", + "template": "experiment_split", + "expected": "valid" }, { - "id": "responses.dependency_error_forbidden_experiment_id", - "template": "dependency_error", + "id": "responses.experiment_split_forbidden_failed", + "template": "experiment_split", "set": { - "/flags/garden-layout/metadata/experiment_id": 301 + "/flags/garden-layout/failed": true, + "/flags/garden-layout/value": null, + "/errorsWhileComputingFlags": true }, "expected": "invalid", "expected_failure": { @@ -1447,23 +402,25 @@ } }, { - "id": "responses.dependency_error_forbidden_variant_key", - "template": "dependency_error", + "id": "responses.experiment_split_flag_without_experiment", + "template": "experiment_split", "set": { - "/flags/garden-layout/metadata/variant_key": "arm_a" + "/flags/garden-layout/metadata/has_experiment": false }, - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } + "expected": "valid" }, { - "id": "responses.dependency_error_forbidden_holdout_id", - "template": "dependency_error", + "id": "responses.rollout_miss_percentage_rollout", + "template": "rollout_miss_percentage_rollout", + "expected": "valid" + }, + { + "id": "responses.rollout_miss_percentage_rollout_forbidden_failed", + "template": "rollout_miss_percentage_rollout", "set": { - "/flags/garden-layout/metadata/holdout_id": 401 + "/flags/garden-layout/failed": true, + "/flags/garden-layout/value": null, + "/errorsWhileComputingFlags": true }, "expected": "invalid", "expected_failure": { @@ -1473,69 +430,71 @@ } }, { - "id": "responses.dependency_error_wrong_condition_index", - "template": "dependency_error", + "id": "responses.rollout_miss_experiment", + "template": "rollout_miss_experiment", + "expected": "valid" + }, + { + "id": "responses.rollout_miss_experiment_forbidden_failed", + "template": "rollout_miss_experiment", "set": { - "/flags/garden-layout/reason/condition_index": 0 + "/flags/garden-layout/failed": true, + "/flags/garden-layout/value": null, + "/errorsWhileComputingFlags": true }, "expected": "invalid", "expected_failure": { "layer": "schema", - "keyword": "type", - "instance_path": "/flags/garden-layout/reason/condition_index" + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" } }, { - "id": "responses.dependency_error_missing_failed", - "template": "dependency_error", - "remove": [ - "/flags/garden-layout/failed" - ], - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "required", - "instance_path": "/flags/garden-layout", - "message_contains": "failed" - } + "id": "responses.experiment_paused", + "template": "experiment_paused", + "expected": "valid" }, { - "id": "responses.dependency_error_nonnull_value", - "template": "dependency_error", + "id": "responses.experiment_paused_forbidden_failed", + "template": "experiment_paused", "set": { - "/flags/garden-layout/value": false + "/flags/garden-layout/failed": true, + "/flags/garden-layout/value": null, + "/errorsWhileComputingFlags": true }, "expected": "invalid", "expected_failure": { "layer": "schema", - "keyword": "type", - "instance_path": "/flags/garden-layout/value" + "keyword": "not", + "instance_path": "/flags/garden-layout/metadata" } }, { - "id": "responses.error", - "template": "error", + "id": "responses.holdout", + "template": "holdout", "expected": "valid" }, { - "id": "responses.error_missing_has_experiment", - "template": "error", + "id": "responses.holdout_missing_rule_id", + "template": "holdout", "remove": [ - "/flags/garden-layout/metadata/has_experiment" + "/flags/garden-layout/metadata/rule_id" ], "expected": "invalid", "expected_failure": { "layer": "presence", "keyword": "required", "instance_path": "/flags/garden-layout/metadata", - "message_contains": "has_experiment" + "message_contains": "rule_id" } }, { - "id": "responses.error_forbidden_rule_type", - "template": "error", + "id": "responses.holdout_forbidden_failed", + "template": "holdout", "set": { - "/flags/garden-layout/metadata/rule_type": "experiment" + "/flags/garden-layout/failed": true, + "/flags/garden-layout/value": null, + "/errorsWhileComputingFlags": true }, "expected": "invalid", "expected_failure": { @@ -1545,70 +504,82 @@ } }, { - "id": "responses.error_forbidden_rule_id", - "template": "error", - "set": { - "/flags/garden-layout/metadata/rule_id": "10000000-0000-4000-8000-000000000001" - }, - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } + "id": "responses.no_rule_match", + "template": "no_rule_match", + "expected": "valid" }, { - "id": "responses.error_forbidden_experiment_id", - "template": "error", + "id": "responses.no_rule_match_forbidden_failed", + "template": "no_rule_match", "set": { - "/flags/garden-layout/metadata/experiment_id": 301 + "/flags/garden-layout/failed": true, + "/flags/garden-layout/value": null, + "/errorsWhileComputingFlags": true }, "expected": "invalid", "expected_failure": { - "layer": "schema", + "layer": "presence", "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" + "instance_path": "/flags/garden-layout" } }, { - "id": "responses.error_forbidden_variant_key", - "template": "error", + "id": "responses.missing_group_key", + "template": "missing_group_key", + "expected": "valid" + }, + { + "id": "responses.missing_group_key_forbidden_failed", + "template": "missing_group_key", "set": { - "/flags/garden-layout/metadata/variant_key": "arm_a" + "/flags/garden-layout/failed": true, + "/flags/garden-layout/value": null, + "/errorsWhileComputingFlags": true }, "expected": "invalid", "expected_failure": { - "layer": "schema", + "layer": "presence", "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" + "instance_path": "/flags/garden-layout" } }, { - "id": "responses.error_forbidden_holdout_id", - "template": "error", - "set": { - "/flags/garden-layout/metadata/holdout_id": 401 - }, + "id": "responses.dependency_error", + "template": "dependency_error", + "expected": "valid" + }, + { + "id": "responses.dependency_error_missing_failed", + "template": "dependency_error", + "remove": [ + "/flags/garden-layout/failed" + ], "expected": "invalid", "expected_failure": { "layer": "schema", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" + "keyword": "required", + "instance_path": "/flags/garden-layout", + "message_contains": "failed" } }, { - "id": "responses.error_wrong_condition_index", - "template": "error", + "id": "responses.dependency_error_nonnull_value", + "template": "dependency_error", "set": { - "/flags/garden-layout/reason/condition_index": 0 + "/flags/garden-layout/value": false }, "expected": "invalid", "expected_failure": { "layer": "schema", "keyword": "type", - "instance_path": "/flags/garden-layout/reason/condition_index" + "instance_path": "/flags/garden-layout/value" } }, + { + "id": "responses.error", + "template": "error", + "expected": "valid" + }, { "id": "responses.error_missing_failed", "template": "error", @@ -1685,19 +656,6 @@ "instance_path": "/flags/garden-layout/metadata/rule_id" } }, - { - "id": "responses.split_wrong_type_experiment_id", - "template": "experiment_split", - "set": { - "/flags/garden-layout/metadata/experiment_id": "301" - }, - "expected": "invalid", - "expected_failure": { - "layer": "schema", - "keyword": "type", - "instance_path": "/flags/garden-layout/metadata/experiment_id" - } - }, { "id": "responses.split_wrong_type_variant_key", "template": "experiment_split", @@ -1947,32 +905,6 @@ "instance_path": "/flags/garden-layout/metadata/rule_type" } }, - { - "id": "responses.rollout_miss_percentage_rollout_wrong_rule_type", - "template": "rollout_miss_percentage_rollout", - "set": { - "/flags/garden-layout/metadata/rule_type": "experiment" - }, - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "const", - "instance_path": "/flags/garden-layout/metadata/rule_type" - } - }, - { - "id": "responses.rollout_miss_experiment_wrong_rule_type", - "template": "rollout_miss_experiment", - "set": { - "/flags/garden-layout/metadata/rule_type": "percentage_rollout" - }, - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "const", - "instance_path": "/flags/garden-layout/metadata/rule_type" - } - }, { "id": "responses.experiment_paused_wrong_rule_type", "template": "experiment_paused", @@ -2091,7 +1023,6 @@ "payload": null, "rule_type": "experiment", "rule_id": "10000000-0000-4000-8000-000000000001", - "experiment_id": 301, "variant_key": "arm_a" } } @@ -2117,7 +1048,6 @@ "payload": null, "rule_type": "experiment", "rule_id": "10000000-0000-4000-8000-000000000001", - "experiment_id": 301, "variant_key": "arm_a" }, "conditions": [ @@ -2157,7 +1087,6 @@ "payload": null, "rule_type": "experiment", "rule_id": "10000000-0000-4000-8000-000000000001", - "experiment_id": 301, "variant_key": "arm_a" } } @@ -2190,19 +1119,6 @@ "instance_path": "/flags/garden-layout/metadata" } }, - { - "id": "responses.v1_forbidden_holdout_id", - "template": "v1", - "set": { - "/flags/garden-layout/metadata/holdout_id": 401 - }, - "expected": "invalid", - "expected_failure": { - "layer": "presence", - "keyword": "not", - "instance_path": "/flags/garden-layout/metadata" - } - }, { "id": "responses.v1_object_payload", "template": "v1", diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py index 0d5a7f2f3..ee5357097 100644 --- a/posthog/test/test_flags_v4_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -25,6 +25,7 @@ FeatureFlagResult, FlagEvaluationDetails, FlagEvaluationErrorCode, + FlagEvaluationReason, FlagMetadata, normalize_flags_response, to_flags_and_payloads, @@ -95,61 +96,30 @@ def _complete_split(details: FlagEvaluationDetails[Any]) -> bool: 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.metadata + metadata = details.flag_metadata return ( details.error_code is None - and metadata is not None - and details.reason is not None - and metadata.config_version == 2 - and details.reason.code == "experiment_split" - and metadata.rule_type == "experiment" - and metadata.rule_id is not None - and metadata.variant_key is not 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 ) -# The pinned contract still lists `metadata.experiment_id` and `metadata.holdout_id` as -# known fields. Records no longer carry either: experiments link to rules on the server, -# and holdouts are rule-local without an ID. The reader ignores both keys like any -# unknown one. Drop these workarounds when the fixtures are re-pinned to a contract -# without the fields. -IGNORED_FIXTURE_METADATA = ("experiment_id", "holdout_id") -# Cases that depend on `experiment_id`, which now read as a complete split. -READER_OVERRIDES: Dict[str, Dict[str, Any]] = { - "readers.partial_split_missing_experiment_id": {"experiment_exposure": True}, - "readers.split_wrong_type_experiment_id": { - "value": False, - "config_version": 2, - "experiment_exposure": True, - "error_code": None, - }, -} - - def test_vendored_fixtures_match_the_recorded_release_digests(): source = _load("SOURCE.json") - assert source["release"] == "1.13.1" - assert source["contract_version"] == "2.3.1" + assert source["release"] == "1.22.0" + assert source["contract_version"] == "7.0.0" for name, entry in source["files"].items(): digest = hashlib.sha256((FIXTURES / name).read_bytes()).hexdigest() assert digest == entry["sha256"], name -def test_fixture_workarounds_name_vendored_cases_and_fields(): - assert set(READER_OVERRIDES) <= {case["id"] for case in READERS["cases"]} - sent = { - field - for case in VALID_RESPONSES - for record in _build(RESPONSES, case)["flags"].values() - for field in record["metadata"] - } - assert set(IGNORED_FIXTURE_METADATA) <= sent - - @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"], **READER_OVERRIDES.get(case["id"], {})} + reader = case["reader"] key = reader["requested_key"] details = _details(snapshot, key, reader["caller_default"], reader["value"]) @@ -160,8 +130,7 @@ def test_reader_fixture(client, case): assert details.error_code == ( FlagEvaluationErrorCode(expected_error) if expected_error else None ) - config_version = details.metadata.config_version if details.metadata else None - assert config_version == reader["config_version"] + 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(): @@ -192,7 +161,8 @@ def test_valid_producer_responses_are_retained_as_sent(case): assert flag.reason.condition_index == record["reason"]["condition_index"] assert isinstance(flag.metadata, FlagMetadata) for field, value in record["metadata"].items(): - if field != "payload" and field not in IGNORED_FIXTURE_METADATA: + # Only config version 1 records carry an experiment ID; it is not kept. + if field not in ("payload", "experiment_id"): assert getattr(flag.metadata, field) == value, field @@ -243,19 +213,29 @@ def _typed_response(values: Dict[str, Any]) -> Dict[str, 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, "object": {"d": 1}} -TYPE_OF = { - "boolean-flag": "boolean", - "false-flag": "boolean", - "string-flag": "string", - "empty-string-flag": "string", - "integer-flag": "number", - "zero-flag": "number", - "float-flag": "number", - "object-flag": "object", - "empty-object-flag": "object", +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": {}}, } @@ -269,19 +249,23 @@ def test_typed_accessors_never_coerce(client, key, value_type): value = TYPED_VALUES[key] assert details.key == key - assert details.reason is not None and details.reason.code == "targeting_match" - assert details.metadata is not None and details.metadata.config_version == 2 - if TYPE_OF.get(key) == value_type: - assert details.value == value - assert type(details.value) is type(value) + 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): @@ -308,15 +292,277 @@ def test_typed_details_carry_the_variant_key_and_context(client): assert details.value == "compact" assert details.variant == "arm_a" - assert details.reason is not None - assert (details.reason.code, details.reason.condition_index) == ( - "experiment_split", - 0, + 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": { + key: {"key": key, "metadata": {"id": 1, "version": 1}, **record} + for key, record in records.items() + }, + "errorsWhileComputingFlags": False, + } + + +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"}, + } + } ) - assert details.metadata is not None - assert details.metadata.rule_type == "experiment" - assert details.metadata.rule_id == "10000000-0000-4000-8000-000000000001" - assert details.metadata.variant_key == "arm_a" + 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": + response = _typed_response({"string-flag": "compact"}) + else: + response = _old_server_response( + **{"string-flag": {"enabled": True, "variant": "compact"}} + ) + client = Client(FAKE_TEST_API_KEY, send=False) + other_client = Client(FAKE_TEST_API_KEY, send=False) + try: + with caplog.at_level("INFO", logger="posthog"): + for _ in range(2): + _evaluate(client, response) + _evaluate(other_client, response) + finally: + client.shutdown() + other_client.shutdown() + + logged = [r for r in caplog.records if "no typed flag values" in r.getMessage()] + assert len(logged) == (0 if shape == "v4" else 2) + + +async def test_the_async_client_logs_records_without_typed_values_once(caplog): + response = _old_server_response(**{"flag": {"enabled": True, "variant": None}}) + with mock.patch( + "posthog.async_client._async_flags", + new=mock.AsyncMock(side_effect=lambda *a, **k: copy.deepcopy(response)), + ): + client = AsyncPosthog("project-key", send=False) + with caplog.at_level("INFO", logger="posthog"): + await client.evaluate_flags(DISTINCT_ID) + snapshot = await client.evaluate_flags(DISTINCT_ID) + await client.shutdown() + + 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): @@ -327,6 +573,7 @@ def test_missing_flag_returns_the_default_with_flag_not_found(client): 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.", ) @@ -419,6 +666,7 @@ def _record(**overrides: Any) -> Dict[str, Any]: ("metadata.has_experiment", "false"), ("metadata.rule_type", 2), ("metadata.rule_id", 3), + ("metadata.experiment_id", "301"), ("metadata.variant_key", True), ("metadata.forced_variant", "yes"), ], @@ -477,15 +725,15 @@ def test_unknown_fields_and_null_optional_fields_are_ignored(): "field, value", [ ("experiment_id", 301), - ("experiment_id", "301"), ("holdout_id", 401), ("holdout_id", "401"), ], ) def test_experiment_and_holdout_ids_in_the_metadata_are_ignored(field, value): - # Records carry no experiment or holdout ID: experiments link to rules on the - # server, and holdouts are rule-local. A record that still sends one reads like a - # record with any other unknown key. + # Only config version 1 records carry an experiment ID, and no record carries a + # holdout ID: experiments link to rules on the server, and holdouts are + # rule-local. Neither is kept. An experiment ID of the wrong type is a parse + # error, like any known field; a holdout ID is an unknown key. record = _record(**{f"metadata.{field}": value}) flag = normalize_flags_response({"flags": {"garden-layout": record}})["flags"][ @@ -896,6 +1144,8 @@ def test_typed_accessors_report_access_like_get_flag(client): 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"] diff --git a/posthog/types.py b/posthog/types.py index 8ea9220a6..176f0c8e5 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -1,7 +1,8 @@ +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, @@ -217,6 +218,33 @@ class FlagEvaluationErrorCode(str, Enum): 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``.""" @@ -450,6 +478,9 @@ def _parse_typed_record(key: str, resp: Any) -> FeatureFlag: raw_metadata.get("forced_variant"), "metadata.forced_variant", bool ), ) + # 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 as error: return FeatureFlag( key=key, @@ -480,92 +511,263 @@ def _parse_typed_record(key: str, resp: Any) -> FeatureFlag: ) +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. - variant: Key of the assigned variant, when the server reported one. - reason: The reason the server gave for the result, when there is one. - metadata: The flag metadata the server returned, when there is any. + 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 - reason: Optional[FlagReason] = None - metadata: Optional[FlagMetadata] = 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 _is_number(value: Any) -> bool: - return isinstance(value, (int, float)) and not isinstance(value, bool) +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 + ), +} -_FLAG_VALUE_TYPES: Dict[str, Callable[[Any], bool]] = { - "boolean": lambda value: isinstance(value, bool), - "string": lambda value: isinstance(value, str), - "number": _is_number, - "object": lambda value: isinstance(value, dict), +# 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 _resolve_flag_details( - key: str, flag: Optional[FeatureFlag], default_value: _T, value_type: str +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 record for a typed accessor without coercing its value. + """Resolve a flag value for a typed accessor without coercing it. - The caller default is returned for an absent flag, a failed or unreadable record, - a ``None`` value, and a value of another type. ``False`` read as another type - returns the default without an error. + 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. """ - if flag is None: - return FlagEvaluationDetails( - key=key, - value=default_value, - 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 - variant = metadata.variant_key if metadata else None def resolved( - value: Any, - error_code: Optional[FlagEvaluationErrorCode] = None, - error_message: Optional[str] = None, + result: Any, + code: Optional[FlagEvaluationErrorCode] = None, + message: Optional[str] = None, + returned_value: bool = False, ) -> FlagEvaluationDetails[Any]: return FlagEvaluationDetails( key=key, - value=value, - variant=variant, - reason=flag.reason, - metadata=metadata, - error_code=error_code, - error_message=error_message, + 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 flag.error_code is not None: - return resolved(default_value, flag.error_code, flag.error_message) - accepts = _FLAG_VALUE_TYPES[value_type] - if accepts(flag.value): - return resolved(flag.value) - if flag.value is None or flag.value is False: + 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, - f"Flag '{key}' does not have a {value_type} value.", + _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, ) +def _has_untyped_flag_records(resp: Any) -> bool: + """Whether a raw ``/flags`` response has a record without a typed ``value``. + + Such a record comes from a server that does not send ``/flags?v=4`` records. + """ + if not isinstance(resp, dict): + return False + flags = resp.get("flags") + if isinstance(flags, dict): + return any( + isinstance(record, dict) and "value" not in record + for record in flags.values() + ) + return bool(resp.get("featureFlags")) + + +_UNTYPED_FLAGS_RESPONSE_MESSAGE = ( + "[FEATURE FLAGS] The /flags response has no typed flag values, so the server" + " 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." +) + + class FlagsResponse(TypedDict, total=False): """Normalized response from the PostHog feature flags API.""" diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index d27390084..9b4daf367 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -23,6 +23,7 @@ alias posthog.FlagDefinitionCacheData -> posthog.flag_definition_cache.FlagDefin 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 @@ -1148,9 +1149,9 @@ 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.metadata: Optional[FlagMetadata] = None -attribute posthog.types.FlagEvaluationDetails.reason: Optional[FlagReason] = None +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' @@ -1158,6 +1159,12 @@ 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 @@ -1168,6 +1175,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 @@ -1351,8 +1359,9 @@ class posthog.types.FeatureFlag(key: str, enabled: bool, variant: Optional[str], 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, variant: Optional[str] = None, reason: Optional[FlagReason] = None, metadata: Optional[FlagMetadata] = None, error_code: Optional[FlagEvaluationErrorCode] = None, error_message: Optional[str] = None) +class posthog.types.FlagEvaluationDetails(key: str, value: _T, reason: FlagEvaluationReason, variant: Optional[str] = None, error_code: Optional[FlagEvaluationErrorCode] = None, error_message: Optional[str] = None, flag_metadata: Dict[str, FlagMetadataValue] = dict()) class posthog.types.FlagEvaluationErrorCode +class posthog.types.FlagEvaluationReason class posthog.types.FlagMetadata(id: 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 @@ -1821,6 +1830,10 @@ method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_boolean_detai 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]] From 460f0c36fdba599489b439780b185be4595ff32c Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Wed, 28 Oct 2026 13:35:32 +0100 Subject: [PATCH 06/35] test(flags): declare the flags_response_v4 compliance capability The adapter sends /flags?v=4, so it declares flags_response_v4. A harness that knows the capability runs flags_request_uses_v4_query_param in place of the ?v=2 check; older harness releases ignore it. --- sdk_compliance_adapter/adapter.py | 2 ++ sdk_compliance_adapter/test_adapter.py | 1 + 2 files changed, 3 insertions(+) diff --git a/sdk_compliance_adapter/adapter.py b/sdk_compliance_adapter/adapter.py index 8ff4ffff7..d205d1792 100644 --- a/sdk_compliance_adapter/adapter.py +++ b/sdk_compliance_adapter/adapter.py @@ -311,6 +311,8 @@ def health(): else ["capture_v0", "capture_ai_v0", "encoding_gzip"] ) capabilities.append("feature_flags_local_evaluation_v1") + # Remote /flags requests send ?v=4 and read the v4 flag record. + capabilities.append("flags_response_v4") return jsonify( { "sdk_name": "posthog-python", diff --git a/sdk_compliance_adapter/test_adapter.py b/sdk_compliance_adapter/test_adapter.py index 8acc78386..0f40f20fc 100644 --- a/sdk_compliance_adapter/test_adapter.py +++ b/sdk_compliance_adapter/test_adapter.py @@ -86,6 +86,7 @@ def test_health_opts_into_local_evaluation_without_losing_capture( monkeypatch.setattr(adapter, "CAPTURE_MODE", mode) capabilities = adapter.app.test_client().get("/health").json["capabilities"] assert "feature_flags_local_evaluation_v1" in capabilities + assert "flags_response_v4" in capabilities assert capability in capabilities assert "capture_ai_v0" in capabilities From 3d3ac5918264e7e6ecfe95e30338be2d0bcbd923 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Thu, 29 Oct 2026 10:49:48 +0100 Subject: [PATCH 07/35] ci(compliance): run harness 1.23.0 for the flags_response_v4 capability --- .github/workflows/sdk-compliance.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sdk-compliance.yml b/.github/workflows/sdk-compliance.yml index b200a0dc1..2df4d111c 100644 --- a/.github/workflows/sdk-compliance.yml +++ b/.github/workflows/sdk-compliance.yml @@ -33,7 +33,7 @@ jobs: with: adapter-dockerfile: "sdk_compliance_adapter/Dockerfile" adapter-context: "." - test-harness-version: "1.1.1" + test-harness-version: "1.23.0" continue-on-error: false report-name: "sdk-compliance-report-v0" @@ -43,6 +43,6 @@ jobs: with: adapter-dockerfile: "sdk_compliance_adapter/Dockerfile.v1" adapter-context: "." - test-harness-version: "1.1.1" + test-harness-version: "1.23.0" continue-on-error: false report-name: "sdk-compliance-report-v1" From ac7b619f926af750ffac8427f495b5446b50d541 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Thu, 29 Oct 2026 13:16:34 +0100 Subject: [PATCH 08/35] feat(flags): name the unresolved cause in typed details --- posthog/feature_flag_evaluations.py | 9 ++++++++- posthog/test/test_flags_v4_response.py | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/posthog/feature_flag_evaluations.py b/posthog/feature_flag_evaluations.py index 61105f1d0..1864e553f 100644 --- a/posthog/feature_flag_evaluations.py +++ b/posthog/feature_flag_evaluations.py @@ -5,7 +5,7 @@ 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 @@ -379,6 +379,13 @@ def _get_typed_details( ) -> 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 diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py index ee5357097..d6f406b0d 100644 --- a/posthog/test/test_flags_v4_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -27,6 +27,7 @@ FlagEvaluationErrorCode, FlagEvaluationReason, FlagMetadata, + UnresolvedFlagReason, normalize_flags_response, to_flags_and_payloads, ) @@ -579,6 +580,28 @@ def test_missing_flag_returns_the_default_with_flag_not_found(client): ) +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", [ From adc46f4533d678806fe5fd4d1b4affdb48bddcaf Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Thu, 29 Oct 2026 13:51:52 +0100 Subject: [PATCH 09/35] refactor(flags): move the typed flag value accessors to a follow-up Keep reading the /flags?v=4 response and retaining each flag's typed value, reason and metadata, with the existing getters derived from the value. Drop the public typed accessors (get_boolean_value/_details and the string, number, integer, float and object forms), FlagEvaluationDetails, FlagEvaluationReason and FlagEvaluationErrorCode, and the error code and message fields on FeatureFlag that only they read. They return in a separate change once the cross-SDK accessor API is agreed. --- .sampo/changesets/flags-v4-response.md | 2 +- 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, 30 insertions(+), 1034 deletions(-) diff --git a/.sampo/changesets/flags-v4-response.md b/.sampo/changesets/flags-v4-response.md index 976dc5397..8fa5e6cbf 100644 --- a/.sampo/changesets/flags-v4-response.md +++ b/.sampo/changesets/flags-v4-response.md @@ -2,4 +2,4 @@ pypi/posthog: minor --- -Request `/flags?v=4` and keep each flag's typed value, reason and metadata. Servers that still send the older response keep working. Existing getters, callbacks and `$feature_flag_called` properties return the same values as before: a number or object value reads as `True`, with the value as its payload. 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. +Request `/flags?v=4` and keep each flag's typed value, reason and metadata internally. Servers that still send the older response keep working. Existing getters, callbacks and `$feature_flag_called` properties return the same values as before: a number or object value reads as `True`, with the value as its payload. The SDK logs once per client when a server sends a response without typed values. No public API is added. diff --git a/posthog/__init__.py b/posthog/__init__.py index 767862a20..a4c3f3678 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -81,9 +81,6 @@ 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 8b879b2c0..122f7b684 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -4782,7 +4782,6 @@ 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 1864e553f..ef4e401d9 100644 --- a/posthog/feature_flag_evaluations.py +++ b/posthog/feature_flag_evaluations.py @@ -5,18 +5,11 @@ values the code branched on, with no additional /flags request. """ -from dataclasses import dataclass, replace +from dataclasses import dataclass from types import MappingProxyType from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Union -from posthog.types import ( - FeatureFlag, - FlagEvaluationDetails, - FlagValue, - UnresolvedFlagReason, - _resolve_flag_details, - _resolve_flag_value, -) +from posthog.types import FeatureFlag, FlagValue, UnresolvedFlagReason @dataclass(frozen=True) @@ -37,8 +30,6 @@ 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 @@ -140,132 +131,6 @@ 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. @@ -275,8 +140,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`, - :meth:`get_flag` or a typed accessor before this call. + """Return a filtered copy containing only flags accessed via :meth:`is_enabled` + or :meth:`get_flag` 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. @@ -374,43 +239,6 @@ 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 d6f406b0d..521e66c70 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, Callable, Dict, List, Optional +from typing import Any, Dict, List, Optional from unittest import mock import pytest @@ -23,11 +23,7 @@ from posthog.types import ( FeatureFlag, FeatureFlagResult, - FlagEvaluationDetails, - FlagEvaluationErrorCode, - FlagEvaluationReason, FlagMetadata, - UnresolvedFlagReason, normalize_flags_response, to_flags_and_payloads, ) @@ -82,32 +78,6 @@ 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" @@ -117,31 +87,6 @@ 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"] @@ -154,7 +99,6 @@ 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 @@ -197,239 +141,6 @@ 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": { @@ -440,29 +151,6 @@ 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": @@ -498,150 +186,16 @@ 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_boolean_value("flag", False) is True + assert snapshot.get_flag("flag") 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 @@ -705,7 +259,6 @@ 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()) == ( @@ -713,9 +266,8 @@ 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.error_code is None + assert sibling.failed is False assert sibling.value == "compact" @@ -724,7 +276,8 @@ def test_a_record_that_is_not_an_object_fails_only_that_record(): flags = normalize_flags_response(response)["flags"] - assert flags["broken"].error_code is FlagEvaluationErrorCode.PARSE_ERROR + assert flags["broken"].failed is True + assert flags["broken"].get_value() is False assert flags["sibling"].value == "compact" @@ -738,7 +291,7 @@ def test_unknown_fields_and_null_optional_fields_are_ignored(): "garden-layout" ] - assert flag.error_code is None + assert flag.failed is False assert flag.value == "compact" assert isinstance(flag.metadata, FlagMetadata) assert flag.metadata.variant_key is None @@ -763,7 +316,7 @@ def test_experiment_and_holdout_ids_in_the_metadata_are_ignored(field, value): "garden-layout" ] - assert flag.error_code is None + assert flag.failed is False assert flag.value == "compact" assert isinstance(flag.metadata, FlagMetadata) assert not hasattr(flag.metadata, field) @@ -800,7 +353,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_returns_the_default(): +def test_a_failed_older_server_record_is_kept_as_failed(): record = { "key": "garden-layout", "enabled": False, @@ -818,8 +371,9 @@ def test_a_failed_older_server_record_returns_the_default(): "garden-layout" ] - assert flag.error_code is FlagEvaluationErrorCode.GENERAL - assert flag.error_message == "Timed out" + assert flag.failed is True + assert flag.value is False + assert flag.reason is not None and flag.reason.description == "Timed out" def test_feature_flag_value_defaults_to_the_legacy_value(): @@ -1158,30 +712,20 @@ 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(TYPED_VALUES)), + new=mock.AsyncMock( + return_value=_typed_response( + {"float-flag": 12.5, "object-flag": {"layout": "compact"}} + ) + ), ): client = AsyncPosthog("project-key", send=False) snapshot = await client.evaluate_flags(DISTINCT_ID) await client.shutdown() - 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("object-flag") is True + assert snapshot.get_flag_payload("object-flag") == {"layout": "compact"} 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 82cd07d22..6084c925d 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -1,26 +1,12 @@ -import copy import json import logging import math -from dataclasses import dataclass, field +from dataclasses import dataclass from enum import Enum -from typing import ( - Any, - Callable, - Dict, - Generic, - List, - Optional, - TypedDict, - TypeVar, - Union, - cast, -) +from typing import Any, Callable, List, Optional, TypedDict, Union, cast FlagValue = Union[bool, str] -_T = TypeVar("_T") - def _reject_json_constant(value: str) -> None: raise ValueError("Invalid JSON constant") @@ -227,51 +213,6 @@ 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``.""" @@ -281,13 +222,6 @@ 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: @@ -308,8 +242,6 @@ 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 @@ -319,8 +251,6 @@ 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: @@ -354,17 +284,13 @@ 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=failed, - error_code=error_code, - error_message=error_message, + failed=resp.get("failed") is True, ) @classmethod @@ -412,21 +338,6 @@ 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)``. @@ -444,7 +355,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, with ``PARSE_ERROR``. An absent or null field reads as absent, + this record, which then reads as disabled. An absent or null field reads as absent, except ``value``, where null means the flag has no value. """ try: @@ -507,7 +418,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 as error: + except _MalformedRecord: return FeatureFlag( key=key, enabled=False, @@ -516,14 +427,8 @@ 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, @@ -532,241 +437,6 @@ 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, ) @@ -788,9 +458,7 @@ 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. 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." + " may not support /flags?v=4. Every flag is read as config version 1." ) diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index f31572aa7..a548f8832 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -21,9 +21,6 @@ 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 @@ -325,7 +322,6 @@ 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 @@ -1133,8 +1129,6 @@ 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] @@ -1156,24 +1150,6 @@ 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 @@ -1184,7 +1160,6 @@ 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 @@ -1368,13 +1343,10 @@ 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, error_code: Optional[FlagEvaluationErrorCode] = None, error_message: Optional[str] = None) +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.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 @@ -1840,20 +1812,8 @@ 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 3f8c315251229e59e4f6c0527666da1d2c9acad1 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Thu, 29 Oct 2026 13:52:03 +0100 Subject: [PATCH 10/35] 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 000000000..e6ac1cc12 --- /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 a4c3f3678..767862a20 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 122f7b684..8b879b2c0 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 ef4e401d9..1864e553f 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 521e66c70..d6f406b0d 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 6084c925d..82cd07d22 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 a548f8832..f31572aa7 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 11/35] 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 1864e553f..db6ad2213 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 d6f406b0d..cab52ab9f 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 82cd07d22..f95c988d9 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 12/35] 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 000000000..9668d4318 --- /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 13d8c7df9..ef4859b46 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 25ca18e51..dec98a762 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 63ce6a504..fda69c51d 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 8d1db309fbe3eb9cd7892f40e33989dd8173dafc Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Thu, 29 Oct 2026 20:51:12 +0100 Subject: [PATCH 13/35] fix(flags): fail only the unreadable record, whatever the server version A record without a typed value now has its known fields type-checked like a /flags?v=4 record, so a wrong-typed field fails only that flag instead of the whole response. A key member of the wrong type fails its record; the map key stays the flag key. An unreadable record keeps the field that failed it, apart from a record the server marks failed. --- posthog/test/test_flags_v4_response.py | 189 +++++++++++++++++++++++++ posthog/types.py | 119 +++++++++++----- 2 files changed, 270 insertions(+), 38 deletions(-) diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py index 521e66c70..d3127aa7d 100644 --- a/posthog/test/test_flags_v4_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -24,6 +24,7 @@ FeatureFlag, FeatureFlagResult, FlagMetadata, + FlagReason, normalize_flags_response, to_flags_and_payloads, ) @@ -266,9 +267,12 @@ def test_a_known_field_with_the_wrong_type_fails_only_that_record(path, value): None, False, ) + # The record keeps that it could not be read, apart from a server failure. + assert malformed._malformed_field == path sibling = flags["sibling"] assert sibling.failed is False assert sibling.value == "compact" + assert sibling._malformed_field is None def test_a_record_that_is_not_an_object_fails_only_that_record(): @@ -376,6 +380,191 @@ def test_a_failed_older_server_record_is_kept_as_failed(): assert flag.reason is not None and flag.reason.description == "Timed out" +def _older_record(**overrides: Any) -> Dict[str, Any]: + record: Dict[str, Any] = { + "key": "garden-layout", + "enabled": True, + "variant": "compact", + "failed": False, + "reason": { + "code": "condition_match", + "condition_index": 0, + "description": "Matched condition set 1", + }, + "metadata": { + "id": 202, + "version": 9, + "description": "Garden layout", + "payload": None, + "has_experiment": False, + }, + } + for path, value in overrides.items(): + *parents, last = path.split(".") + target = record + for token in parents: + target = target[token] + target[last] = value + return record + + +def test_one_unreadable_older_server_record_keeps_its_siblings(): + response = { + "flags": { + "bad": { + "enabled": True, + "variant": None, + "reason": "oops", + "metadata": {"id": 1, "version": 1, "payload": None}, + }, + "good": { + "enabled": True, + "variant": None, + "reason": None, + "metadata": {"id": 2, "version": 1, "payload": None}, + }, + }, + "requestId": "x", + } + + flags = normalize_flags_response(response)["flags"] + + assert flags["bad"].failed is True + assert flags["bad"]._malformed_field == "reason" + assert flags["good"].failed is False + assert flags["good"].get_value() is True + + +@pytest.mark.parametrize( + "path, value", + [ + ("key", 123), + ("enabled", "true"), + ("variant", 3), + ("failed", "yes"), + ("reason", "condition_match"), + ("reason.code", 3), + ("reason.condition_index", "0"), + ("reason.condition_index", 0.5), + ("reason.description", ["Matched"]), + ("metadata", []), + ("metadata.id", "202"), + ("metadata.version", True), + ("metadata.description", 1), + ("metadata.has_experiment", "false"), + ], +) +def test_a_known_field_with_the_wrong_type_fails_only_that_older_server_record( + path, value +): + response = { + "flags": { + "garden-layout": _older_record(**{path: value}), + "sibling": _older_record(key="sibling"), + } + } + + flags = normalize_flags_response(response)["flags"] + + malformed = flags["garden-layout"] + assert malformed.key == "garden-layout" + assert malformed.failed is True + assert malformed._malformed_field == path + assert malformed.value is None + assert (malformed.enabled, malformed.variant, malformed.get_value()) == ( + False, + None, + False, + ) + sibling = flags["sibling"] + assert ( + sibling + == normalize_flags_response( + {"flags": {"sibling": _older_record(key="sibling")}} + )["flags"]["sibling"] + ) + assert (sibling.failed, sibling.value, sibling._malformed_field) == ( + False, + "compact", + None, + ) + + +def test_a_valid_older_server_record_renders_as_before(): + flag = normalize_flags_response({"flags": {"garden-layout": _older_record()}})[ + "flags" + ]["garden-layout"] + + assert flag == FeatureFlag( + key="garden-layout", + enabled=True, + variant="compact", + reason=FlagReason( + code="condition_match", + condition_index=0, + description="Matched condition set 1", + ), + metadata=FlagMetadata( + id=202, + payload=None, + version=9, + description="Garden layout", + has_experiment=False, + config_version=1, + ), + ) + + +@pytest.mark.parametrize("make_record", [_record, _older_record]) +def test_a_record_key_of_the_wrong_type_fails_only_that_record(make_record): + flags = normalize_flags_response( + { + "flags": { + "garden-layout": make_record(key=123), + "sibling": make_record(key="sibling"), + } + } + )["flags"] + + assert flags["garden-layout"].key == "garden-layout" + assert flags["garden-layout"].failed is True + assert flags["garden-layout"]._malformed_field == "key" + assert flags["sibling"].failed is False + + +@pytest.mark.parametrize("make_record", [_record, _older_record]) +@pytest.mark.parametrize("key", ["other-flag", None]) +def test_a_record_is_read_under_its_map_key(make_record, key): + # Reader rules fail a record only for a field of the wrong JSON type. A string + # key that differs from the map key is a producer error, so the map key wins. + record = make_record(key=key) + + flag = normalize_flags_response({"flags": {"garden-layout": record}})["flags"][ + "garden-layout" + ] + + assert flag.key == "garden-layout" + assert flag.failed is False + assert flag.value == "compact" + + +def test_a_server_failed_record_is_not_marked_unreadable(): + flags = normalize_flags_response( + { + "flags": { + "v4": _build(RESPONSES, {"template": "error"})["flags"][ + "garden-layout" + ], + "older": _older_record(failed=True, enabled=False, variant=None), + } + } + )["flags"] + + for flag in flags.values(): + assert flag.failed is True + assert flag._malformed_field is None + + 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 diff --git a/posthog/types.py b/posthog/types.py index 6084c925d..fce054da2 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -1,9 +1,9 @@ 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, List, Optional, TypedDict, Union, cast FlagValue = Union[bool, str] @@ -251,6 +251,12 @@ class FeatureFlag: metadata: Union[FlagMetadata, LegacyFlagMetadata] value: Any = _DERIVED_VALUE failed: bool = False + # The wire path of the field that made the record unreadable, such as + # ``"metadata.id"``. ``None`` unless the record could not be read; a record the + # server marks failed has ``failed`` set and this ``None``. + _malformed_field: Optional[str] = field( + default=None, init=False, repr=False, compare=False + ) def __post_init__(self) -> None: if self.value is _DERIVED_VALUE: @@ -271,27 +277,7 @@ def from_json(cls, resp: Any) -> "FeatureFlag": without one comes from an older server: its value is ``variant``, or ``enabled`` when there is no variant, and its config version is ``1``. """ - if "value" in resp: - return _parse_typed_record(resp.get("key"), resp) - - reason = None - if resp.get("reason"): - reason = FlagReason.from_json(resp.get("reason")) - - metadata = None - if resp.get("metadata"): - metadata = FlagMetadata.from_json(resp.get("metadata")) - else: - metadata = LegacyFlagMetadata(payload=None) - - return cls( - key=resp.get("key"), - enabled=resp.get("enabled"), - variant=resp.get("variant"), - reason=reason, - metadata=metadata, - failed=resp.get("failed") is True, - ) + return _parse_record(resp.get("key"), resp) @classmethod def from_value_and_payload( @@ -361,6 +347,7 @@ def _parse_typed_record(key: str, resp: Any) -> FeatureFlag: try: if not isinstance(resp, dict): raise _MalformedRecord("record") + _read_record_key(resp) value = _read_value(resp.get("value")) failed = _read_typed(resp.get("failed"), "failed", bool) is True @@ -418,16 +405,8 @@ 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: - return FeatureFlag( - key=key, - enabled=False, - variant=None, - reason=None, - metadata=LegacyFlagMetadata(payload=None), - value=None, - failed=True, - ) + except _MalformedRecord as error: + return _malformed_record(key, str(error)) return FeatureFlag( key=key, @@ -440,6 +419,74 @@ def _parse_typed_record(key: str, resp: Any) -> FeatureFlag: ) +def _parse_older_record(key: str, resp: Dict[str, Any]) -> FeatureFlag: + """Parse a record without a typed ``value``, from a server before ``/flags?v=4``. + + Its value is ``variant``, or ``enabled`` when there is no variant, and its config + version is ``1``. As for a ``/flags?v=4`` record, a known field with the wrong + JSON type fails only this record. + """ + try: + _read_record_key(resp) + _read_typed(resp.get("enabled"), "enabled", bool) + _read_typed(resp.get("variant"), "variant", str) + _read_typed(resp.get("failed"), "failed", bool) + raw_reason = _read_typed(resp.get("reason"), "reason", dict) + if raw_reason: + _read_typed(raw_reason.get("code"), "reason.code", str) + _read_int(raw_reason.get("condition_index"), "reason.condition_index") + _read_typed(raw_reason.get("description"), "reason.description", str) + raw_metadata = _read_typed(resp.get("metadata"), "metadata", dict) + if raw_metadata: + _read_int(raw_metadata.get("id"), "metadata.id") + _read_int(raw_metadata.get("version"), "metadata.version") + _read_typed(raw_metadata.get("description"), "metadata.description", str) + _read_typed( + raw_metadata.get("has_experiment"), "metadata.has_experiment", bool + ) + except _MalformedRecord as error: + return _malformed_record(key, str(error)) + + return FeatureFlag( + key=key, + # An older server always sends ``enabled``; a record without it keeps + # ``None``, as before. + enabled=cast(bool, resp.get("enabled")), + variant=resp.get("variant"), + reason=FlagReason.from_json(raw_reason), + metadata=FlagMetadata.from_json(raw_metadata), + failed=resp.get("failed") is True, + ) + + +def _read_record_key(resp: Dict[str, Any]) -> None: + # The flag key is the record's map key. A ``key`` member of the wrong type fails + # the record, like any known field; a string that differs is ignored. + _read_typed(resp.get("key"), "key", str) + + +def _malformed_record(key: str, malformed_field: str) -> FeatureFlag: + """A record with a known field of the wrong JSON type: failed, and disabled.""" + flag = FeatureFlag( + key=key, + enabled=False, + variant=None, + reason=None, + metadata=LegacyFlagMetadata(payload=None), + value=None, + failed=True, + ) + object.__setattr__(flag, "_malformed_field", malformed_field) + return flag + + +def _parse_record(key: str, resp: Any) -> FeatureFlag: + """Parse one record of a ``/flags`` response, stored under ``key``.""" + if isinstance(resp, dict) and "value" not in resp: + return _parse_older_record(key, resp) + return _parse_typed_record(key, resp) + + def _has_untyped_flag_records(resp: Any) -> bool: """Whether a raw ``/flags`` response has a record without a typed ``value``. @@ -592,11 +639,7 @@ def normalize_flags_response(resp: Any) -> FlagsResponse: for key, value in flags.items(): if isinstance(value, FeatureFlag): continue - if not isinstance(value, dict): - flags[key] = _parse_typed_record(key, value) - continue - value["key"] = key - flags[key] = FeatureFlag.from_json(value) + flags[key] = _parse_record(key, value) else: # Handle legacy format featureFlags = resp.get("featureFlags", {}) From 1a00f0dba4041c6d6673e4a18dc26e62ddbaf591 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Thu, 29 Oct 2026 20:51:19 +0100 Subject: [PATCH 14/35] fix(flags): keep a flag ID and version absent when the record has none A record without metadata.id or metadata.version no longer reads them as 0. Events already left out a falsy ID and version, so their properties do not change. --- posthog/test/test_feature_flag.py | 2 +- posthog/test/test_flags_v4_response.py | 37 ++++++++++++++++++++++++++ posthog/types.py | 16 +++++------ references/public_api_snapshot.txt | 6 ++--- 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/posthog/test/test_feature_flag.py b/posthog/test/test_feature_flag.py index 4d9d02f60..411fbf9fc 100644 --- a/posthog/test/test_feature_flag.py +++ b/posthog/test/test_feature_flag.py @@ -128,7 +128,7 @@ def test_flag_metadata_from_json(self): metadata = FlagMetadata.from_json(resp) self.assertEqual(metadata.id, 123) self.assertIsNone(metadata.payload) - self.assertEqual(metadata.version, 0) # default value + self.assertIsNone(metadata.version) # absent, not invented self.assertEqual(metadata.description, "") # default value # Test with None diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py index d3127aa7d..1127dd18e 100644 --- a/posthog/test/test_flags_v4_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -565,6 +565,43 @@ def test_a_server_failed_record_is_not_marked_unreadable(): assert flag._malformed_field is None +@pytest.mark.parametrize("make_record", [_record, _older_record]) +def test_absent_flag_id_and_version_stay_absent(make_record, client): + record = make_record() + record["key"] = "garden-layout" + del record["metadata"]["id"] + del record["metadata"]["version"] + response = { + "flags": {"garden-layout": record}, + "errorsWhileComputingFlags": False, + "requestId": "60000000-0000-4000-8000-000000000000", + } + + flag = normalize_flags_response(copy.deepcopy(response))["flags"]["garden-layout"] + assert isinstance(flag.metadata, FlagMetadata) + assert (flag.metadata.id, flag.metadata.version) == (None, None) + + with ( + mock.patch("posthog.client.flags", return_value=copy.deepcopy(response)), + mock.patch.object(client, "capture") as capture, + ): + client.get_feature_flag_result("garden-layout", DISTINCT_ID) + properties = capture.call_args.kwargs["properties"] + assert "$feature_flag_id" not in properties + assert "$feature_flag_version" not in properties + + # A new client, so the event is not deduplicated against the one above. + snapshot_client = Client(FAKE_TEST_API_KEY, send=False) + try: + with mock.patch.object(snapshot_client, "capture") as capture: + _evaluate(snapshot_client, response).get_flag("garden-layout") + finally: + snapshot_client.shutdown() + properties = capture.call_args.kwargs["properties"] + assert "$feature_flag_id" not in properties + assert "$feature_flag_version" not in properties + + 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 diff --git a/posthog/types.py b/posthog/types.py index fce054da2..f11f0e079 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -163,11 +163,11 @@ class FlagMetadata: """Feature flag metadata returned by the feature flag API. Attributes: - id: Numeric feature flag ID. + id: Numeric feature flag ID, or ``None`` when the record has none. payload: Payload configured for the matched flag value, if any. For a number or object value it is the value encoded as JSON, the way servers before ``/flags?v=4`` sent it. - version: Feature flag version. + version: Feature flag version, or ``None`` when the record has none. description: Feature flag description. has_experiment: Whether the flag has a linked experiment. ``None`` when the server does not report the field (older deployments). @@ -180,9 +180,9 @@ class FlagMetadata: result. """ - id: int + id: Optional[int] payload: Optional[str] - version: int + version: Optional[int] description: str has_experiment: Optional[bool] = None config_version: Optional[int] = None @@ -202,9 +202,9 @@ def from_json(cls, resp: Any) -> Union["FlagMetadata", LegacyFlagMetadata]: return LegacyFlagMetadata(payload=None) raw_has_experiment = resp.get("has_experiment") return cls( - id=resp.get("id", 0), + id=resp.get("id"), payload=resp.get("payload"), - version=resp.get("version", 0), + version=resp.get("version"), description=resp.get("description", ""), has_experiment=raw_has_experiment if isinstance(raw_has_experiment, bool) @@ -377,9 +377,9 @@ def _parse_typed_record(key: str, resp: Any) -> FeatureFlag: else: payload = raw_metadata.get("payload") metadata = FlagMetadata( - id=_read_int(raw_metadata.get("id"), "metadata.id") or 0, + id=_read_int(raw_metadata.get("id"), "metadata.id"), payload=payload if payload is not None else encoded_payload, - version=_read_int(raw_metadata.get("version"), "metadata.version") or 0, + version=_read_int(raw_metadata.get("version"), "metadata.version"), description=_read_typed( raw_metadata.get("description", ""), "metadata.description", str ), diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index a548f8832..74a37b0e1 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -1154,12 +1154,12 @@ attribute posthog.types.FlagMetadata.config_version: Optional[int] = None attribute posthog.types.FlagMetadata.description: str attribute posthog.types.FlagMetadata.forced_variant: Optional[bool] = None attribute posthog.types.FlagMetadata.has_experiment: Optional[bool] = None -attribute posthog.types.FlagMetadata.id: int +attribute posthog.types.FlagMetadata.id: Optional[int] attribute posthog.types.FlagMetadata.payload: Optional[str] attribute posthog.types.FlagMetadata.rule_id: Optional[str] = None attribute posthog.types.FlagMetadata.rule_type: Optional[str] = None attribute posthog.types.FlagMetadata.variant_key: Optional[str] = None -attribute posthog.types.FlagMetadata.version: int +attribute posthog.types.FlagMetadata.version: Optional[int] attribute posthog.types.FlagReason.code: str attribute posthog.types.FlagReason.condition_index: Optional[int] attribute posthog.types.FlagReason.description: str @@ -1347,7 +1347,7 @@ class posthog.types.FeatureFlag(key: str, enabled: bool, variant: Optional[str], class posthog.types.FeatureFlagError class posthog.types.FeatureFlagEvaluationRuntime class posthog.types.FeatureFlagResult(key: str, enabled: bool, variant: Optional[str], payload: Optional[Any], reason: Optional[str]) -class posthog.types.FlagMetadata(id: 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.FlagMetadata(id: Optional[int], payload: Optional[str], version: Optional[int], description: str, has_experiment: Optional[bool] = None, config_version: Optional[int] = None, rule_type: Optional[str] = None, rule_id: Optional[str] = None, variant_key: Optional[str] = None, forced_variant: Optional[bool] = None) class posthog.types.FlagReason(code: str, condition_index: Optional[int], description: str) class posthog.types.FlagsAndPayloads class posthog.types.FlagsResponse From 8584fb550bd5d1a1e8089a075e0939f7e0fdce66 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Thu, 29 Oct 2026 20:51:25 +0100 Subject: [PATCH 15/35] fix(flags): make a number or object value its own payload A /flags?v=4 record with a number or object value exposes that value as its payload, even when the record also sends a payload. Other payloads are kept as sent. --- posthog/test/test_flags_v4_response.py | 27 ++++++++++++++++++++++++++ posthog/types.py | 7 +++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py index 1127dd18e..b0d0a66ec 100644 --- a/posthog/test/test_flags_v4_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -602,6 +602,33 @@ def test_absent_flag_id_and_version_stay_absent(make_record, client): assert "$feature_flag_version" not in properties +@pytest.mark.parametrize( + "value, encoded", [(25, "25"), ({"columns": 2}, '{"columns":2}')] +) +def test_a_number_or_object_value_is_its_own_payload(value, encoded, client): + # A config version 2 number or object value is its payload; a payload the + # record also sends is ignored. + response = _typed_response({"upload-limit": value}) + response["flags"]["upload-limit"]["metadata"]["payload"] = "999" + + flag = normalize_flags_response(copy.deepcopy(response))["flags"]["upload-limit"] + assert flag.metadata.payload == encoded + + snapshot = _evaluate(client, response) + assert snapshot.get_flag_payload("upload-limit") == value + + +def test_a_config_version_1_payload_is_kept(): + response = _typed_response({"bool-flag": True, "string-flag": "compact"}) + for record in response["flags"].values(): + record["metadata"].update(config_version=1, payload='{"columns": 2}') + + flags = normalize_flags_response(response)["flags"] + + assert flags["bool-flag"].metadata.payload == '{"columns": 2}' + assert flags["string-flag"].metadata.payload == '{"columns": 2}' + + 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 diff --git a/posthog/types.py b/posthog/types.py index f11f0e079..48437db1e 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -375,10 +375,13 @@ def _parse_typed_record(key: str, resp: Any) -> FeatureFlag: if raw_metadata is None: metadata = LegacyFlagMetadata(payload=encoded_payload) else: - payload = raw_metadata.get("payload") metadata = FlagMetadata( id=_read_int(raw_metadata.get("id"), "metadata.id"), - payload=payload if payload is not None else encoded_payload, + # A number or object value is its own payload; a payload the record + # also carries is ignored. + payload=encoded_payload + if encoded_payload is not None + else raw_metadata.get("payload"), version=_read_int(raw_metadata.get("version"), "metadata.version"), description=_read_typed( raw_metadata.get("description", ""), "metadata.description", str From 808385fd43bc93bbc642f7483bb952261846e63e Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Thu, 29 Oct 2026 20:51:25 +0100 Subject: [PATCH 16/35] docs(changeset): list the public fields the v4 response adds --- .sampo/changesets/flags-v4-response.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.sampo/changesets/flags-v4-response.md b/.sampo/changesets/flags-v4-response.md index 8fa5e6cbf..593eb35a0 100644 --- a/.sampo/changesets/flags-v4-response.md +++ b/.sampo/changesets/flags-v4-response.md @@ -2,4 +2,4 @@ pypi/posthog: minor --- -Request `/flags?v=4` and keep each flag's typed value, reason and metadata internally. Servers that still send the older response keep working. Existing getters, callbacks and `$feature_flag_called` properties return the same values as before: a number or object value reads as `True`, with the value as its payload. The SDK logs once per client when a server sends a response without typed values. No public API is added. +Request `/flags?v=4` and keep each flag's typed value, reason and metadata. Servers that still send the older response keep working, and a flag record with a field of the wrong JSON type fails only that flag. Existing getters, callbacks and `$feature_flag_called` properties return the same values as before: a number or object value reads as `True`, with the value as its payload. The SDK logs once per client when a server sends a response without typed values. `FeatureFlag` gains `value` (the typed value) and `failed` (the flag failed to evaluate or its record could not be read), and `FlagMetadata` gains `config_version`, `rule_type`, `rule_id`, `variant_key` and `forced_variant`. `FlagMetadata.id` and `FlagMetadata.version` are `None` when a record has none, instead of `0`. From 94b5afdac33c3117bbf1d823f41f0e11d2f4aa2b Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Thu, 29 Oct 2026 20:53:12 +0100 Subject: [PATCH 17/35] 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 a25d90eff..c2e16ae4f 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 18/35] 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 db6ad2213..13669e05a 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 c2e16ae4f..a10a48294 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 14e267a8f..e37ca3da0 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 5000d44a99a25fb4210fd32621985c4a7484490e Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Thu, 29 Oct 2026 21:49:58 +0100 Subject: [PATCH 19/35] fix(flags): fail only the record whose value is too deep to encode A value nested too deeply for json.dumps used to abort the whole response. Also test an empty-string reason, and the async client with unreadable and sparse records beside a healthy one. The changeset now says which results can differ from before. --- .sampo/changesets/flags-v4-response.md | 2 +- posthog/test/test_flags_v4_response.py | 54 ++++++++++++++++++++++++++ posthog/types.py | 3 ++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/.sampo/changesets/flags-v4-response.md b/.sampo/changesets/flags-v4-response.md index 593eb35a0..c3f5f2a2b 100644 --- a/.sampo/changesets/flags-v4-response.md +++ b/.sampo/changesets/flags-v4-response.md @@ -2,4 +2,4 @@ pypi/posthog: minor --- -Request `/flags?v=4` and keep each flag's typed value, reason and metadata. Servers that still send the older response keep working, and a flag record with a field of the wrong JSON type fails only that flag. Existing getters, callbacks and `$feature_flag_called` properties return the same values as before: a number or object value reads as `True`, with the value as its payload. The SDK logs once per client when a server sends a response without typed values. `FeatureFlag` gains `value` (the typed value) and `failed` (the flag failed to evaluate or its record could not be read), and `FlagMetadata` gains `config_version`, `rule_type`, `rule_id`, `variant_key` and `forced_variant`. `FlagMetadata.id` and `FlagMetadata.version` are `None` when a record has none, instead of `0`. +Request `/flags?v=4` and keep each flag's typed value, reason and metadata. Servers that still send the older response keep working, and a flag record with a field of the wrong JSON type fails only that flag. For valid flag records, existing getters, callbacks and `$feature_flag_called` properties return the same values as before: a number or object value reads as `True`, with the value as its payload. Two things can differ: the human-readable reason text of a config version 2 flag is the one the server sends at v4, and a record with a known field of the wrong JSON type (for example a string `reason`) now reads as a failed flag instead of being read anyway or failing the whole response. The SDK logs once per client when a server sends a response without typed values. `FeatureFlag` gains `value` (the typed value) and `failed` (the flag failed to evaluate or its record could not be read), and `FlagMetadata` gains `config_version`, `rule_type`, `rule_id`, `variant_key` and `forced_variant`. `FlagMetadata.id` and `FlagMetadata.version` are `None` when a record has none, instead of `0`, so their type is now `Optional[int]`. diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py index b0d0a66ec..98ab739e4 100644 --- a/posthog/test/test_flags_v4_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -435,6 +435,26 @@ def test_one_unreadable_older_server_record_keeps_its_siblings(): assert flags["good"].get_value() is True +def test_a_value_too_deeply_nested_to_encode_fails_only_that_record(): + deep: Dict[str, Any] = {} + inner = deep + for _ in range(200_000): + inner["a"] = {} + inner = inner["a"] + response = { + "flags": { + "deep": _record(key="deep", value=deep), + "sibling": _record(key="sibling"), + } + } + + flags = normalize_flags_response(response)["flags"] + + assert flags["deep"].failed is True + assert flags["deep"]._malformed_field == "value" + assert flags["sibling"].get_value() == "compact" + + @pytest.mark.parametrize( "path, value", [ @@ -443,6 +463,7 @@ def test_one_unreadable_older_server_record_keeps_its_siblings(): ("variant", 3), ("failed", "yes"), ("reason", "condition_match"), + ("reason", ""), ("reason.code", 3), ("reason.condition_index", "0"), ("reason.condition_index", 0.5), @@ -965,6 +986,39 @@ def test_feature_flag_called_properties_are_unchanged_for_a_v1_flag(client): ) +async def test_async_client_keeps_healthy_flags_beside_unreadable_and_sparse_ones(): + sparse = _record(key="sparse") + del sparse["metadata"]["id"], sparse["metadata"]["version"] + response = { + "flags": { + "bad-older": _older_record(key="bad-older", reason="oops"), + "bad-v4": _record(key="bad-v4", **{"metadata.id": "202"}), + "sparse": sparse, + "healthy": _older_record(key="healthy"), + }, + "requestId": "x", + } + with mock.patch( + "posthog.async_client._async_flags", new=mock.AsyncMock(return_value=response) + ): + client = AsyncPosthog("project-key", send=False) + with mock.patch.object(client, "capture") as capture: + snapshot = await client.evaluate_flags(DISTINCT_ID) + assert snapshot.get_flag("bad-older") is False + assert snapshot.get_flag("bad-v4") is False + assert snapshot.get_flag("sparse") == "compact" + assert snapshot.get_flag("healthy") == "compact" + await client.shutdown() + + sparse_event = next( + call.kwargs["properties"] + for call in capture.call_args_list + if call.kwargs["properties"]["$feature_flag"] == "sparse" + ) + assert "$feature_flag_id" not in sparse_event + assert "$feature_flag_version" not in sparse_event + + 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 48437db1e..d8c21adb9 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -410,6 +410,9 @@ def _parse_typed_record(key: str, resp: Any) -> FeatureFlag: _read_int(raw_metadata.get("experiment_id"), "metadata.experiment_id") except _MalformedRecord as error: 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 FeatureFlag( key=key, From 8333a516764277ccaef88b49ff66f0ee178a42b0 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Thu, 29 Oct 2026 21:50:24 +0100 Subject: [PATCH 20/35] 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 e6ac1cc12..87cf62994 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 21/35] 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 1cec4ef9f..fcc5c6949 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 ac1b32cec..13e790cc3 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 ad1b1fba0c6be48a6aa35844ff6ed628a55b1785 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Fri, 30 Oct 2026 10:00:32 +0100 Subject: [PATCH 22/35] refactor(flags): keep the v4 value and rule metadata fields private get_flags_decision() returns FeatureFlag and FlagMetadata, so public fields could be adopted before the typed flag API is settled. The typed accessors make them public. --- .sampo/changesets/flags-v4-response.md | 2 +- posthog/test/test_feature_flag.py | 22 ++++-- posthog/test/test_flags_v4_response.py | 96 ++++++++++++++---------- posthog/types.py | 100 +++++++++++++++---------- references/public_api_snapshot.txt | 11 +-- 5 files changed, 132 insertions(+), 99 deletions(-) diff --git a/.sampo/changesets/flags-v4-response.md b/.sampo/changesets/flags-v4-response.md index c3f5f2a2b..4682a45b7 100644 --- a/.sampo/changesets/flags-v4-response.md +++ b/.sampo/changesets/flags-v4-response.md @@ -2,4 +2,4 @@ pypi/posthog: minor --- -Request `/flags?v=4` and keep each flag's typed value, reason and metadata. Servers that still send the older response keep working, and a flag record with a field of the wrong JSON type fails only that flag. For valid flag records, existing getters, callbacks and `$feature_flag_called` properties return the same values as before: a number or object value reads as `True`, with the value as its payload. Two things can differ: the human-readable reason text of a config version 2 flag is the one the server sends at v4, and a record with a known field of the wrong JSON type (for example a string `reason`) now reads as a failed flag instead of being read anyway or failing the whole response. The SDK logs once per client when a server sends a response without typed values. `FeatureFlag` gains `value` (the typed value) and `failed` (the flag failed to evaluate or its record could not be read), and `FlagMetadata` gains `config_version`, `rule_type`, `rule_id`, `variant_key` and `forced_variant`. `FlagMetadata.id` and `FlagMetadata.version` are `None` when a record has none, instead of `0`, so their type is now `Optional[int]`. +Request `/flags?v=4` and read each flag's typed value, reason and metadata. This release adds no new public API for them yet. Servers that still send the older response keep working, and a flag record with a field of the wrong JSON type fails only that flag. For valid flag records, existing getters, callbacks and `$feature_flag_called` properties return the same values as before: a number or object value reads as `True`, with the value as its payload. Two things can differ: the human-readable reason text of a config version 2 flag is the one the server sends at v4, and a record with a known field of the wrong JSON type (for example a string `reason`) now reads as a failed flag instead of being read anyway or failing the whole response. The SDK logs once per client when a server sends a response without typed values. `FlagMetadata.id` and `FlagMetadata.version` are `None` when a record has none, instead of `0`, so their type is now `Optional[int]`. diff --git a/posthog/test/test_feature_flag.py b/posthog/test/test_feature_flag.py index 411fbf9fc..29db7e61b 100644 --- a/posthog/test/test_feature_flag.py +++ b/posthog/test/test_feature_flag.py @@ -1,6 +1,12 @@ import unittest -from posthog.types import FeatureFlag, FlagMetadata, FlagReason, LegacyFlagMetadata +from posthog.types import ( + FeatureFlag, + FlagMetadata, + FlagReason, + LegacyFlagMetadata, + _set_private_fields, +) class TestFeatureFlag(unittest.TestCase): @@ -38,12 +44,14 @@ def test_feature_flag_from_json(self): ) self.assertEqual( flag.metadata, - FlagMetadata( - id=1, - payload='{"some": "json"}', - version=2, - description="test-description", - config_version=1, + _set_private_fields( + FlagMetadata( + id=1, + payload='{"some": "json"}', + version=2, + description="test-description", + ), + _config_version=1, ), ) diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py index 98ab739e4..a0b3011ab 100644 --- a/posthog/test/test_flags_v4_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -25,6 +25,7 @@ FeatureFlagResult, FlagMetadata, FlagReason, + _set_private_fields, normalize_flags_response, to_flags_and_payloads, ) @@ -88,6 +89,15 @@ def test_vendored_fixtures_match_the_recorded_release_digests(): assert digest == entry["sha256"], name +# The /flags?v=4 metadata fields that stay private until the typed accessors ship. +_PRIVATE_METADATA_FIELDS = { + "config_version", + "rule_type", + "rule_id", + "variant_key", + "forced_variant", +} + VALID_RESPONSES = [case for case in RESPONSES["cases"] if case["expected"] == "valid"] @@ -100,8 +110,8 @@ def test_valid_producer_responses_are_retained_as_sent(case): assert set(flags) == set(records) for key, record in records.items(): flag = flags[key] - assert flag.value == record["value"] - assert flag.failed is record.get("failed", False) + assert flag._value == record["value"] + assert flag._failed is record.get("failed", False) assert flag.reason is not None assert flag.reason.code == record["reason"]["code"] assert flag.reason.condition_index == record["reason"]["condition_index"] @@ -109,6 +119,8 @@ def test_valid_producer_responses_are_retained_as_sent(case): for field, value in record["metadata"].items(): # Only config version 1 records carry an experiment ID; it is not kept. if field not in ("payload", "experiment_id"): + if field in _PRIVATE_METADATA_FIELDS: + field = f"_{field}" assert getattr(flag.metadata, field) == value, field @@ -260,8 +272,8 @@ def test_a_known_field_with_the_wrong_type_fails_only_that_record(path, value): flags = normalize_flags_response(response)["flags"] malformed = flags["garden-layout"] - assert malformed.failed is True - assert malformed.value is None + assert malformed._failed is True + assert malformed._value is None assert (malformed.enabled, malformed.variant, malformed.get_value()) == ( False, None, @@ -270,8 +282,8 @@ def test_a_known_field_with_the_wrong_type_fails_only_that_record(path, value): # The record keeps that it could not be read, apart from a server failure. assert malformed._malformed_field == path sibling = flags["sibling"] - assert sibling.failed is False - assert sibling.value == "compact" + assert sibling._failed is False + assert sibling._value == "compact" assert sibling._malformed_field is None @@ -280,9 +292,9 @@ 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"]._failed is True assert flags["broken"].get_value() is False - assert flags["sibling"].value == "compact" + assert flags["sibling"]._value == "compact" def test_unknown_fields_and_null_optional_fields_are_ignored(): @@ -295,10 +307,10 @@ def test_unknown_fields_and_null_optional_fields_are_ignored(): "garden-layout" ] - assert flag.failed is False - assert flag.value == "compact" + assert flag._failed is False + assert flag._value == "compact" assert isinstance(flag.metadata, FlagMetadata) - assert flag.metadata.variant_key is None + assert flag.metadata._variant_key is None @pytest.mark.parametrize( @@ -320,8 +332,8 @@ def test_experiment_and_holdout_ids_in_the_metadata_are_ignored(field, value): "garden-layout" ] - assert flag.failed is False - assert flag.value == "compact" + assert flag._failed is False + assert flag._value == "compact" assert isinstance(flag.metadata, FlagMetadata) assert not hasattr(flag.metadata, field) @@ -349,12 +361,12 @@ def test_a_record_without_value_is_read_as_an_older_server_record(): "garden-layout" ] - assert flag.value == "compact" + assert flag._value == "compact" assert isinstance(flag.metadata, FlagMetadata) - assert flag.metadata.config_version == 1 - assert flag.metadata.rule_type is None - assert flag.metadata.rule_id is None - assert flag.metadata.variant_key is None + assert flag.metadata._config_version == 1 + assert flag.metadata._rule_type is None + assert flag.metadata._rule_id is None + assert flag.metadata._variant_key is None def test_a_failed_older_server_record_is_kept_as_failed(): @@ -375,8 +387,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._failed is True + assert flag._value is False assert flag.reason is not None and flag.reason.description == "Timed out" @@ -429,9 +441,9 @@ def test_one_unreadable_older_server_record_keeps_its_siblings(): flags = normalize_flags_response(response)["flags"] - assert flags["bad"].failed is True + assert flags["bad"]._failed is True assert flags["bad"]._malformed_field == "reason" - assert flags["good"].failed is False + assert flags["good"]._failed is False assert flags["good"].get_value() is True @@ -450,7 +462,7 @@ def test_a_value_too_deeply_nested_to_encode_fails_only_that_record(): flags = normalize_flags_response(response)["flags"] - assert flags["deep"].failed is True + assert flags["deep"]._failed is True assert flags["deep"]._malformed_field == "value" assert flags["sibling"].get_value() == "compact" @@ -489,9 +501,9 @@ def test_a_known_field_with_the_wrong_type_fails_only_that_older_server_record( malformed = flags["garden-layout"] assert malformed.key == "garden-layout" - assert malformed.failed is True + assert malformed._failed is True assert malformed._malformed_field == path - assert malformed.value is None + assert malformed._value is None assert (malformed.enabled, malformed.variant, malformed.get_value()) == ( False, None, @@ -504,7 +516,7 @@ def test_a_known_field_with_the_wrong_type_fails_only_that_older_server_record( {"flags": {"sibling": _older_record(key="sibling")}} )["flags"]["sibling"] ) - assert (sibling.failed, sibling.value, sibling._malformed_field) == ( + assert (sibling._failed, sibling._value, sibling._malformed_field) == ( False, "compact", None, @@ -525,13 +537,15 @@ def test_a_valid_older_server_record_renders_as_before(): condition_index=0, description="Matched condition set 1", ), - metadata=FlagMetadata( - id=202, - payload=None, - version=9, - description="Garden layout", - has_experiment=False, - config_version=1, + metadata=_set_private_fields( + FlagMetadata( + id=202, + payload=None, + version=9, + description="Garden layout", + has_experiment=False, + ), + _config_version=1, ), ) @@ -548,9 +562,9 @@ def test_a_record_key_of_the_wrong_type_fails_only_that_record(make_record): )["flags"] assert flags["garden-layout"].key == "garden-layout" - assert flags["garden-layout"].failed is True + assert flags["garden-layout"]._failed is True assert flags["garden-layout"]._malformed_field == "key" - assert flags["sibling"].failed is False + assert flags["sibling"]._failed is False @pytest.mark.parametrize("make_record", [_record, _older_record]) @@ -565,8 +579,8 @@ def test_a_record_is_read_under_its_map_key(make_record, key): ] assert flag.key == "garden-layout" - assert flag.failed is False - assert flag.value == "compact" + assert flag._failed is False + assert flag._value == "compact" def test_a_server_failed_record_is_not_marked_unreadable(): @@ -582,7 +596,7 @@ def test_a_server_failed_record_is_not_marked_unreadable(): )["flags"] for flag in flags.values(): - assert flag.failed is True + assert flag._failed is True assert flag._malformed_field is None @@ -651,9 +665,9 @@ def test_a_config_version_1_payload_is_kept(): def test_feature_flag_value_defaults_to_the_legacy_value(): - assert FeatureFlag.from_value_and_payload("a", "control", None).value == "control" - assert FeatureFlag.from_value_and_payload("a", True, None).value is True - assert FeatureFlag.from_value_and_payload("a", False, None).value is False + assert FeatureFlag.from_value_and_payload("a", "control", None)._value == "control" + assert FeatureFlag.from_value_and_payload("a", True, None)._value is True + assert FeatureFlag.from_value_and_payload("a", False, None)._value is False # Legacy accessor parity: a v4 record and the equivalent older-server record must give diff --git a/posthog/types.py b/posthog/types.py index d8c21adb9..bc1dee736 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -3,7 +3,17 @@ import math from dataclasses import dataclass, field from enum import Enum -from typing import Any, Callable, Dict, List, Optional, TypedDict, Union, cast +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + TypedDict, + TypeVar, + Union, + cast, +) FlagValue = Union[bool, str] @@ -171,13 +181,6 @@ class FlagMetadata: description: Feature flag description. has_experiment: Whether the flag has a linked experiment. ``None`` when the server does not report the field (older deployments). - config_version: Configuration format of the flag. Responses without a - typed ``value`` always read as ``1``. - rule_type: Type of the rule that produced the result, when one did. - rule_id: ID of the rule that produced the result, when one did. - variant_key: Key of the assigned variant, when one was assigned. - forced_variant: Whether a condition-level variant override produced the - result. """ id: Optional[int] @@ -185,11 +188,16 @@ class FlagMetadata: version: Optional[int] description: str has_experiment: Optional[bool] = None - config_version: Optional[int] = None - rule_type: Optional[str] = None - rule_id: Optional[str] = None - variant_key: Optional[str] = None - forced_variant: Optional[bool] = None + # Work in progress: the ``/flags?v=4`` metadata fields are private until the + # typed flag value accessors ship, which make them public. ``_config_version`` + # is the configuration format of the flag (``1`` for a record without a typed + # ``value``); the rule fields name the rule that produced the result, and the + # variant fields the assigned variant. + _config_version: Optional[int] = field(default=None, init=False) + _rule_type: Optional[str] = field(default=None, init=False) + _rule_id: Optional[str] = field(default=None, init=False) + _variant_key: Optional[str] = field(default=None, init=False) + _forced_variant: Optional[bool] = field(default=None, init=False) @classmethod def from_json(cls, resp: Any) -> Union["FlagMetadata", LegacyFlagMetadata]: @@ -201,7 +209,7 @@ def from_json(cls, resp: Any) -> Union["FlagMetadata", LegacyFlagMetadata]: if not resp: return LegacyFlagMetadata(payload=None) raw_has_experiment = resp.get("has_experiment") - return cls( + metadata = cls( id=resp.get("id"), payload=resp.get("payload"), version=resp.get("version"), @@ -209,12 +217,22 @@ def from_json(cls, resp: Any) -> Union["FlagMetadata", LegacyFlagMetadata]: has_experiment=raw_has_experiment if isinstance(raw_has_experiment, bool) else None, - config_version=1, ) + return _set_private_fields(metadata, _config_version=1) + + +_T = TypeVar("_T") + + +def _set_private_fields(obj: _T, **fields: Any) -> _T: + """Set the private, work-in-progress fields of a frozen dataclass.""" + for name, value in fields.items(): + object.__setattr__(obj, name, value) + return obj class _DerivedValue: - """Default of ``FeatureFlag.value``: derive the value from ``variant`` and ``enabled``.""" + """Default of ``FeatureFlag._value``: derive the value from ``variant`` and ``enabled``.""" def __repr__(self) -> str: return "" @@ -227,7 +245,6 @@ def __repr__(self) -> str: class FeatureFlag: """Detailed feature flag evaluation returned by the flags API. - ``value``, ``failed``, ``reason`` and ``metadata`` are the evaluation details. ``enabled``, ``variant`` and ``metadata.payload`` are the value rendered the way responses before ``/flags?v=4`` carried it, which the legacy getters return. @@ -237,11 +254,6 @@ class FeatureFlag: variant: Variant key for multivariate flags, otherwise ``None``. reason: Optional reason metadata explaining the result. metadata: Payload and other metadata returned by the API. - value: The typed flag value: a boolean, string, number, object, or - ``None`` when the flag has no value. Defaults to ``variant``, or - ``enabled`` when there is no variant. - failed: Whether the flag failed to evaluate, or its record could not be - read. """ key: str @@ -249,20 +261,25 @@ class FeatureFlag: variant: Optional[str] reason: Optional[FlagReason] metadata: Union[FlagMetadata, LegacyFlagMetadata] - value: Any = _DERIVED_VALUE - failed: bool = False + # Work in progress: private until the typed flag value accessors ship, which + # make them public. ``_value`` is the typed flag value: a boolean, string, + # number, object, or ``None`` when the flag has no value; it defaults to + # ``variant``, or ``enabled`` when there is no variant. ``_failed`` says whether + # the flag failed to evaluate, or its record could not be read. + _value: Any = field(default=_DERIVED_VALUE, init=False) + _failed: bool = field(default=False, init=False) # The wire path of the field that made the record unreadable, such as # ``"metadata.id"``. ``None`` unless the record could not be read; a record the - # server marks failed has ``failed`` set and this ``None``. + # server marks failed has ``_failed`` set and this ``None``. _malformed_field: Optional[str] = field( default=None, init=False, repr=False, compare=False ) def __post_init__(self) -> None: - if self.value is _DERIVED_VALUE: + if self._value is _DERIVED_VALUE: object.__setattr__( self, - "value", + "_value", self.variant if self.variant is not None else self.enabled, ) @@ -389,19 +406,22 @@ def _parse_typed_record(key: str, resp: Any) -> FeatureFlag: has_experiment=_read_typed( raw_metadata.get("has_experiment"), "metadata.has_experiment", bool ), - config_version=_read_int( + ) + _set_private_fields( + metadata, + _config_version=_read_int( raw_metadata.get("config_version"), "metadata.config_version" ), - rule_type=_read_typed( + _rule_type=_read_typed( raw_metadata.get("rule_type"), "metadata.rule_type", str ), - rule_id=_read_typed( + _rule_id=_read_typed( raw_metadata.get("rule_id"), "metadata.rule_id", str ), - variant_key=_read_typed( + _variant_key=_read_typed( raw_metadata.get("variant_key"), "metadata.variant_key", str ), - forced_variant=_read_typed( + _forced_variant=_read_typed( raw_metadata.get("forced_variant"), "metadata.forced_variant", bool ), ) @@ -414,15 +434,14 @@ def _parse_typed_record(key: str, resp: Any) -> FeatureFlag: # A value nested too deeply to encode as a payload fails only this record. return _malformed_record(key, "value") - return FeatureFlag( + flag = FeatureFlag( key=key, enabled=enabled, variant=variant, reason=reason, metadata=metadata, - value=value, - failed=failed, ) + return _set_private_fields(flag, _value=value, _failed=failed) def _parse_older_record(key: str, resp: Dict[str, Any]) -> FeatureFlag: @@ -453,7 +472,7 @@ def _parse_older_record(key: str, resp: Dict[str, Any]) -> FeatureFlag: except _MalformedRecord as error: return _malformed_record(key, str(error)) - return FeatureFlag( + flag = FeatureFlag( key=key, # An older server always sends ``enabled``; a record without it keeps # ``None``, as before. @@ -461,8 +480,8 @@ def _parse_older_record(key: str, resp: Dict[str, Any]) -> FeatureFlag: variant=resp.get("variant"), reason=FlagReason.from_json(raw_reason), metadata=FlagMetadata.from_json(raw_metadata), - failed=resp.get("failed") is True, ) + return _set_private_fields(flag, _failed=resp.get("failed") is True) def _read_record_key(resp: Dict[str, Any]) -> None: @@ -479,11 +498,10 @@ def _malformed_record(key: str, malformed_field: str) -> FeatureFlag: variant=None, reason=None, metadata=LegacyFlagMetadata(payload=None), - value=None, - failed=True, ) - object.__setattr__(flag, "_malformed_field", malformed_field) - return flag + return _set_private_fields( + flag, _value=None, _failed=True, _malformed_field=malformed_field + ) def _parse_record(key: str, resp: Any) -> FeatureFlag: diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 74a37b0e1..841ebabbc 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -1129,11 +1129,9 @@ 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.failed: bool = False attribute posthog.types.FeatureFlag.key: str attribute posthog.types.FeatureFlag.metadata: Union[FlagMetadata, LegacyFlagMetadata] attribute posthog.types.FeatureFlag.reason: Optional[FlagReason] -attribute posthog.types.FeatureFlag.value: Any = _DERIVED_VALUE attribute posthog.types.FeatureFlag.variant: Optional[str] attribute posthog.types.FeatureFlagError.CONNECTION_ERROR = 'connection_error' attribute posthog.types.FeatureFlagError.ERRORS_WHILE_COMPUTING = 'errors_while_computing_flags' @@ -1150,15 +1148,10 @@ 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.FlagMetadata.config_version: Optional[int] = None attribute posthog.types.FlagMetadata.description: str -attribute posthog.types.FlagMetadata.forced_variant: Optional[bool] = None attribute posthog.types.FlagMetadata.has_experiment: Optional[bool] = None attribute posthog.types.FlagMetadata.id: Optional[int] attribute posthog.types.FlagMetadata.payload: Optional[str] -attribute posthog.types.FlagMetadata.rule_id: Optional[str] = None -attribute posthog.types.FlagMetadata.rule_type: Optional[str] = None -attribute posthog.types.FlagMetadata.variant_key: Optional[str] = None attribute posthog.types.FlagMetadata.version: Optional[int] attribute posthog.types.FlagReason.code: str attribute posthog.types.FlagReason.condition_index: Optional[int] @@ -1343,11 +1336,11 @@ 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]) class posthog.types.FeatureFlagError class posthog.types.FeatureFlagEvaluationRuntime class posthog.types.FeatureFlagResult(key: str, enabled: bool, variant: Optional[str], payload: Optional[Any], reason: Optional[str]) -class posthog.types.FlagMetadata(id: Optional[int], payload: Optional[str], version: Optional[int], description: str, has_experiment: Optional[bool] = None, 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.FlagMetadata(id: Optional[int], payload: Optional[str], version: Optional[int], description: str, has_experiment: Optional[bool] = None) class posthog.types.FlagReason(code: str, condition_index: Optional[int], description: str) class posthog.types.FlagsAndPayloads class posthog.types.FlagsResponse From 4425340d9af113bd3784492ff3fc6fad81d35f4e Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Fri, 30 Oct 2026 14:03:53 +0100 Subject: [PATCH 23/35] 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 534ffc516..d956f8361 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 e6200655f..dff958699 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 fcc5c6949..57736739e 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 2b212c127..5bf5ded20 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 24/35] 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 d956f8361..4ab8015e9 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 eb5b7af59..a6836e8be 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 dff958699..8e1784f35 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 57736739e..a5fb25c9e 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 25/35] 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 4ab8015e9..1920b0b9e 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 8e1784f35..0360757e6 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 a5fb25c9e..05e40e551 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 13e790cc3..1f762f2d9 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 26/35] 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 1920b0b9e..7b5b53f2e 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 323b16b89..167e2e21e 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 a6836e8be..ff24962cb 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 0360757e6..d4f825761 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 05e40e551..22ec8de8c 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 5bf5ded20..6bb9743a0 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 27/35] 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 7b5b53f2e..ea408e641 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 d4f825761..611084f80 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 22ec8de8c..f019e7516 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 1f762f2d9..e3845043d 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 28/35] 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 167e2e21e..a218c6d67 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 ff24962cb..60d081eff 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 From c5c53f2f1183b4bd00d9031c6e31cb573b7ce594 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Mon, 2 Nov 2026 12:41:53 +0100 Subject: [PATCH 29/35] fix(flags): keep the private v4 details out of the public dataclass fields The typed value, failure flags and rule metadata were declared as dataclass fields, so repr, equality, hashing, dataclasses.asdict and the generated SDK reference showed them. Keep them as instance attributes with class-level defaults instead, import FeatureFlag privately in the snapshot module, and say in the changeset that the details stay internal. --- .sampo/changesets/flags-v4-response.md | 2 +- posthog/feature_flag_evaluations.py | 5 ++- posthog/test/test_feature_flag.py | 21 +++------- posthog/test/test_flags_v4_response.py | 48 +++++++++++++++++----- posthog/types.py | 57 +++++++++++--------------- references/public_api_snapshot.txt | 1 - 6 files changed, 72 insertions(+), 62 deletions(-) diff --git a/.sampo/changesets/flags-v4-response.md b/.sampo/changesets/flags-v4-response.md index 4682a45b7..829f5b02f 100644 --- a/.sampo/changesets/flags-v4-response.md +++ b/.sampo/changesets/flags-v4-response.md @@ -2,4 +2,4 @@ pypi/posthog: minor --- -Request `/flags?v=4` and read each flag's typed value, reason and metadata. This release adds no new public API for them yet. Servers that still send the older response keep working, and a flag record with a field of the wrong JSON type fails only that flag. For valid flag records, existing getters, callbacks and `$feature_flag_called` properties return the same values as before: a number or object value reads as `True`, with the value as its payload. Two things can differ: the human-readable reason text of a config version 2 flag is the one the server sends at v4, and a record with a known field of the wrong JSON type (for example a string `reason`) now reads as a failed flag instead of being read anyway or failing the whole response. The SDK logs once per client when a server sends a response without typed values. `FlagMetadata.id` and `FlagMetadata.version` are `None` when a record has none, instead of `0`, so their type is now `Optional[int]`. +Request `/flags?v=4` and read each flag's typed value, reason and metadata. They stay internal for now: this release adds no public methods, fields or types for them, and `repr`, equality and `dataclasses.asdict` of a flag show only its existing fields. Servers that still send the older response keep working, and a flag record with a field of the wrong JSON type fails only that flag. For valid flag records, existing getters, callbacks and `$feature_flag_called` properties return the same values as before: a number or object value reads as `True`, with the value as its payload. Two things can differ: the human-readable reason text of a config version 2 flag is the one the server sends at v4, and a record with a known field of the wrong JSON type (for example a string `reason`) now reads as a failed flag instead of being read anyway or failing the whole response. The SDK logs once per client when a server sends a response without typed values. `FlagMetadata.id` and `FlagMetadata.version` are `None` when a record has none, instead of `0`, so their type is now `Optional[int]`. diff --git a/posthog/feature_flag_evaluations.py b/posthog/feature_flag_evaluations.py index ef4e401d9..52c290764 100644 --- a/posthog/feature_flag_evaluations.py +++ b/posthog/feature_flag_evaluations.py @@ -9,7 +9,8 @@ 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 as _FeatureFlag +from posthog.types import FlagValue, UnresolvedFlagReason @dataclass(frozen=True) @@ -29,7 +30,7 @@ class _EvaluatedFlagRecord: has_experiment: Optional[bool] = None # The parsed /flags record, with its typed value, reason and metadata. # ``None`` for a locally evaluated flag. - details: Optional[FeatureFlag] = None + details: Optional[_FeatureFlag] = None @dataclass diff --git a/posthog/test/test_feature_flag.py b/posthog/test/test_feature_flag.py index 29db7e61b..147a8186f 100644 --- a/posthog/test/test_feature_flag.py +++ b/posthog/test/test_feature_flag.py @@ -1,12 +1,6 @@ import unittest -from posthog.types import ( - FeatureFlag, - FlagMetadata, - FlagReason, - LegacyFlagMetadata, - _set_private_fields, -) +from posthog.types import FeatureFlag, FlagMetadata, FlagReason, LegacyFlagMetadata class TestFeatureFlag(unittest.TestCase): @@ -44,14 +38,11 @@ def test_feature_flag_from_json(self): ) self.assertEqual( flag.metadata, - _set_private_fields( - FlagMetadata( - id=1, - payload='{"some": "json"}', - version=2, - description="test-description", - ), - _config_version=1, + FlagMetadata( + id=1, + payload='{"some": "json"}', + version=2, + description="test-description", ), ) diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py index a0b3011ab..ca052c5d6 100644 --- a/posthog/test/test_flags_v4_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -10,6 +10,7 @@ import copy import hashlib import json +from dataclasses import asdict from pathlib import Path from typing import Any, Dict, List, Optional from unittest import mock @@ -25,7 +26,6 @@ FeatureFlagResult, FlagMetadata, FlagReason, - _set_private_fields, normalize_flags_response, to_flags_and_payloads, ) @@ -537,19 +537,47 @@ def test_a_valid_older_server_record_renders_as_before(): condition_index=0, description="Matched condition set 1", ), - metadata=_set_private_fields( - FlagMetadata( - id=202, - payload=None, - version=9, - description="Garden layout", - has_experiment=False, - ), - _config_version=1, + metadata=FlagMetadata( + id=202, + payload=None, + version=9, + description="Garden layout", + has_experiment=False, ), ) +def test_the_private_v4_details_stay_out_of_the_public_dataclass_fields(): + # The typed value and rule metadata are private until the typed accessors ship: + # repr, equality, hashing and asdict() of a parsed flag see only public fields. + record = _record(value={"beds": 4}, **{"metadata.rule_id": "rule-1"}) + flag = normalize_flags_response({"flags": {"garden-layout": record}})["flags"][ + "garden-layout" + ] + assert flag._value == {"beds": 4} + assert flag.metadata._rule_id == "rule-1" + + public = FeatureFlag( + key=flag.key, + enabled=flag.enabled, + variant=flag.variant, + reason=flag.reason, + metadata=FlagMetadata( + id=flag.metadata.id, + payload=flag.metadata.payload, + version=flag.metadata.version, + description=flag.metadata.description, + has_experiment=flag.metadata.has_experiment, + ), + ) + assert flag == public + assert hash(flag) == hash(public) + assert repr(flag) == repr(public) + assert asdict(flag) == asdict(public) + fields = [*asdict(flag), *asdict(flag)["metadata"]] + assert not [name for name in fields if name.startswith("_")] + + @pytest.mark.parametrize("make_record", [_record, _older_record]) def test_a_record_key_of_the_wrong_type_fails_only_that_record(make_record): flags = normalize_flags_response( diff --git a/posthog/types.py b/posthog/types.py index bc1dee736..19ccd71e8 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -1,11 +1,12 @@ import json import logging import math -from dataclasses import dataclass, field +from dataclasses import dataclass from enum import Enum from typing import ( Any, Callable, + ClassVar, Dict, List, Optional, @@ -188,16 +189,18 @@ class FlagMetadata: version: Optional[int] description: str has_experiment: Optional[bool] = None - # Work in progress: the ``/flags?v=4`` metadata fields are private until the - # typed flag value accessors ship, which make them public. ``_config_version`` - # is the configuration format of the flag (``1`` for a record without a typed + # Work in progress: the ``/flags?v=4`` metadata is private until the typed flag + # value accessors ship. It is kept in instance attributes, not dataclass fields, + # so ``repr``, equality, hashing and ``dataclasses.asdict`` see only the public + # fields; these class attributes are the defaults. ``_config_version`` is the + # configuration format of the flag (``1`` for a record without a typed # ``value``); the rule fields name the rule that produced the result, and the # variant fields the assigned variant. - _config_version: Optional[int] = field(default=None, init=False) - _rule_type: Optional[str] = field(default=None, init=False) - _rule_id: Optional[str] = field(default=None, init=False) - _variant_key: Optional[str] = field(default=None, init=False) - _forced_variant: Optional[bool] = field(default=None, init=False) + _config_version: ClassVar[Optional[int]] = None + _rule_type: ClassVar[Optional[str]] = None + _rule_id: ClassVar[Optional[str]] = None + _variant_key: ClassVar[Optional[str]] = None + _forced_variant: ClassVar[Optional[bool]] = None @classmethod def from_json(cls, resp: Any) -> Union["FlagMetadata", LegacyFlagMetadata]: @@ -225,22 +228,12 @@ def from_json(cls, resp: Any) -> Union["FlagMetadata", LegacyFlagMetadata]: def _set_private_fields(obj: _T, **fields: Any) -> _T: - """Set the private, work-in-progress fields of a frozen dataclass.""" + """Set the private, work-in-progress attributes of a frozen dataclass.""" for name, value in fields.items(): object.__setattr__(obj, name, value) return obj -class _DerivedValue: - """Default of ``FeatureFlag._value``: derive the value from ``variant`` and ``enabled``.""" - - def __repr__(self) -> str: - return "" - - -_DERIVED_VALUE: Any = _DerivedValue() - - @dataclass(frozen=True) class FeatureFlag: """Detailed feature flag evaluation returned by the flags API. @@ -261,27 +254,25 @@ class FeatureFlag: variant: Optional[str] reason: Optional[FlagReason] metadata: Union[FlagMetadata, LegacyFlagMetadata] - # Work in progress: private until the typed flag value accessors ship, which - # make them public. ``_value`` is the typed flag value: a boolean, string, + # Work in progress: private until the typed flag value accessors ship. Like the + # private ``FlagMetadata`` attributes, these are instance attributes, not + # dataclass fields. ``_value`` is the typed flag value: a boolean, string, # number, object, or ``None`` when the flag has no value; it defaults to # ``variant``, or ``enabled`` when there is no variant. ``_failed`` says whether # the flag failed to evaluate, or its record could not be read. - _value: Any = field(default=_DERIVED_VALUE, init=False) - _failed: bool = field(default=False, init=False) + _value: ClassVar[Any] = None + _failed: ClassVar[bool] = False # The wire path of the field that made the record unreadable, such as # ``"metadata.id"``. ``None`` unless the record could not be read; a record the # server marks failed has ``_failed`` set and this ``None``. - _malformed_field: Optional[str] = field( - default=None, init=False, repr=False, compare=False - ) + _malformed_field: ClassVar[Optional[str]] = None def __post_init__(self) -> None: - if self._value is _DERIVED_VALUE: - object.__setattr__( - self, - "_value", - self.variant if self.variant is not None else self.enabled, - ) + object.__setattr__( + self, + "_value", + self.variant if self.variant is not None else self.enabled, + ) def get_value(self) -> FlagValue: return self.variant or self.enabled diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 841ebabbc..f566fb2d3 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -321,7 +321,6 @@ alias posthog.exception_capture.BucketedRateLimiter -> posthog.bucketed_rate_lim 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.FlagValue -> posthog.types.FlagValue alias posthog.feature_flag_evaluations.UnresolvedFlagReason -> posthog.types.UnresolvedFlagReason alias posthog.feature_flags.FlagValue -> posthog.types.FlagValue From bd9c1ada3b4e620567ea56260fa891d42d0b0e97 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Mon, 2 Nov 2026 19:41:35 +0100 Subject: [PATCH 30/35] fix(flags): keep the last good cached value when a record is unreadable A /flags record with a known field of the wrong type reads as disabled. The single-flag getter cached that result, replacing the last good value, so a later failed request fell back to False. An unreadable record now leaves the fallback cache alone and falls back to its value, like a failed request does; without a cached value it still reads as disabled. --- posthog/client.py | 12 +++++- posthog/test/test_flags_v4_response.py | 54 ++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/posthog/client.py b/posthog/client.py index b469c8341..77b724d9f 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -4181,9 +4181,19 @@ def _get_feature_flag_result( flag_details, override_match_value ) + if ( + flag_details is not None + and flag_details._malformed_field is not None + ): + # The record could not be read, so it reads as disabled. It must + # not replace the last good cached value, which it falls back to + # like a failed request. + flag_result = ( + self._get_stale_flag_fallback(distinct_id, key) or flag_result + ) # The request-start generation is an invalidation boundary, not # a claim about the server's definitions. Refresh rejects late writes. - if self.flag_cache and flag_result: + elif self.flag_cache and flag_result: self.flag_cache._set_cached_remote_flag( distinct_id, key, flag_result, local_definition_version ) diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py index ca052c5d6..91da7c7a1 100644 --- a/posthog/test/test_flags_v4_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -20,6 +20,7 @@ from posthog import AsyncPosthog from posthog.client import Client from posthog.feature_flag_evaluations import FeatureFlagEvaluations +from posthog.request import RequestsTimeout from posthog.test.test_utils import FAKE_TEST_API_KEY from posthog.types import ( FeatureFlag, @@ -213,6 +214,59 @@ def test_a_failed_record_never_renders_its_value(client): assert snapshot.is_enabled("garden-layout", True) is False +@pytest.mark.parametrize( + "make_record", + [ + lambda **overrides: _record(**{"metadata.config_version": 1, **overrides}), + lambda **overrides: _older_record(**overrides), + ], + ids=["v4", "older"], +) +def test_an_unreadable_record_keeps_the_last_good_cached_value(make_record): + responses: List[Any] = [ + {"flags": {"garden-layout": make_record()}, "requestId": "r1"}, + {"flags": {"garden-layout": make_record(reason="oops")}, "requestId": "r2"}, + RequestsTimeout("timed out"), + ] + + def flags(*args: Any, **kwargs: Any) -> Dict[str, Any]: + response = responses.pop(0) + if isinstance(response, Exception): + raise response + return response + + client = Client( + FAKE_TEST_API_KEY, + send=False, + flag_fallback_cache_url="memory://local/?ttl=300&size=100", + ) + try: + with mock.patch("posthog.client.flags", side_effect=flags): + values = [ + client.get_feature_flag( + "garden-layout", DISTINCT_ID, send_feature_flag_events=False + ) + for _ in range(3) + ] + finally: + client.shutdown() + + # The unreadable record falls back to the cached value instead of replacing it, + # so the timeout after it still has the last good value. + assert values == ["compact", "compact", "compact"] + + +def test_an_unreadable_record_without_a_cached_value_reads_as_disabled(client): + response = {"flags": {"garden-layout": _older_record(reason="oops")}} + with mock.patch("posthog.client.flags", return_value=response): + assert ( + client.get_feature_flag( + "garden-layout", DISTINCT_ID, send_feature_flag_events=False + ) + is False + ) + + def _record(**overrides: Any) -> Dict[str, Any]: record: Dict[str, Any] = { "key": "garden-layout", From 837b32605e3a8c2c8fbde0f52cfad54dea1c2191 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Mon, 2 Nov 2026 19:42:26 +0100 Subject: [PATCH 31/35] fix(flags): build the subclass FeatureFlag.from_json is called on FeatureFlag.from_json delegated to record parsers that always built a FeatureFlag, so a subclass calling it got a base instance. The parsers now take the class to build, for v4, older and unreadable records alike. --- posthog/test/test_flags_v4_response.py | 35 ++++++++++++++++++++++++++ posthog/types.py | 32 ++++++++++++----------- 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py index 91da7c7a1..cdbaa5a99 100644 --- a/posthog/test/test_flags_v4_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -752,6 +752,41 @@ def test_feature_flag_value_defaults_to_the_legacy_value(): assert FeatureFlag.from_value_and_payload("a", False, None)._value is False +class _CustomFlag(FeatureFlag): + pass + + +class _CustomMetadata(FlagMetadata): + pass + + +class _CustomReason(FlagReason): + pass + + +@pytest.mark.parametrize( + "record", + [ + _record(), + _older_record(), + {"key": "garden-layout", "enabled": True}, + _record(**{"metadata.id": "201"}), + _older_record(reason="oops"), + ], + ids=["v4", "older", "minimal-older", "unreadable-v4", "unreadable-older"], +) +def test_from_json_builds_the_subclass_it_is_called_on(record): + flag = _CustomFlag.from_json(record) + + assert type(flag) is _CustomFlag + assert flag.key == "garden-layout" + + +def test_metadata_and_reason_from_json_build_the_subclass_they_are_called_on(): + assert type(_CustomMetadata.from_json({"id": 1, "version": 2})) is _CustomMetadata + assert type(_CustomReason.from_json({"code": "condition_match"})) is _CustomReason + + # Legacy accessor parity: a v4 record and the equivalent older-server record must give # identical legacy results. diff --git a/posthog/types.py b/posthog/types.py index 19ccd71e8..5250c234b 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -10,6 +10,7 @@ Dict, List, Optional, + Type, TypedDict, TypeVar, Union, @@ -285,7 +286,7 @@ def from_json(cls, resp: Any) -> "FeatureFlag": without one comes from an older server: its value is ``variant``, or ``enabled`` when there is no variant, and its config version is ``1``. """ - return _parse_record(resp.get("key"), resp) + return _parse_record(cls, resp.get("key"), resp) @classmethod def from_value_and_payload( @@ -303,6 +304,9 @@ def from_value_and_payload( ) +_F = TypeVar("_F", bound=FeatureFlag) + + class _MalformedRecord(Exception): """A known field of a flag record has the wrong JSON type.""" @@ -345,7 +349,7 @@ def _legacy_rendering(value: Any) -> tuple[bool, Optional[str], Optional[str]]: return True, None, json.dumps(value, separators=(",", ":"), ensure_ascii=False) -def _parse_typed_record(key: str, resp: Any) -> FeatureFlag: +def _parse_typed_record(cls: Type[_F], key: str, resp: Any) -> _F: """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 @@ -420,12 +424,12 @@ def _parse_typed_record(key: str, resp: Any) -> FeatureFlag: # like every known field, but not kept. _read_int(raw_metadata.get("experiment_id"), "metadata.experiment_id") except _MalformedRecord as error: - return _malformed_record(key, str(error)) + return _malformed_record(cls, 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(cls, key, "value") - flag = FeatureFlag( + flag = cls( key=key, enabled=enabled, variant=variant, @@ -435,7 +439,7 @@ def _parse_typed_record(key: str, resp: Any) -> FeatureFlag: return _set_private_fields(flag, _value=value, _failed=failed) -def _parse_older_record(key: str, resp: Dict[str, Any]) -> FeatureFlag: +def _parse_older_record(cls: Type[_F], key: str, resp: Dict[str, Any]) -> _F: """Parse a record without a typed ``value``, from a server before ``/flags?v=4``. Its value is ``variant``, or ``enabled`` when there is no variant, and its config @@ -461,9 +465,9 @@ def _parse_older_record(key: str, resp: Dict[str, Any]) -> FeatureFlag: raw_metadata.get("has_experiment"), "metadata.has_experiment", bool ) except _MalformedRecord as error: - return _malformed_record(key, str(error)) + return _malformed_record(cls, key, str(error)) - flag = FeatureFlag( + flag = cls( key=key, # An older server always sends ``enabled``; a record without it keeps # ``None``, as before. @@ -481,9 +485,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(cls: Type[_F], key: str, malformed_field: str) -> _F: """A record with a known field of the wrong JSON type: failed, and disabled.""" - flag = FeatureFlag( + flag = cls( key=key, enabled=False, variant=None, @@ -495,11 +499,11 @@ def _malformed_record(key: str, malformed_field: str) -> FeatureFlag: ) -def _parse_record(key: str, resp: Any) -> FeatureFlag: +def _parse_record(cls: Type[_F], key: str, resp: Any) -> _F: """Parse one record of a ``/flags`` response, stored under ``key``.""" if isinstance(resp, dict) and "value" not in resp: - return _parse_older_record(key, resp) - return _parse_typed_record(key, resp) + return _parse_older_record(cls, key, resp) + return _parse_typed_record(cls, key, resp) def _has_untyped_flag_records(resp: Any) -> bool: @@ -654,7 +658,7 @@ def normalize_flags_response(resp: Any) -> FlagsResponse: for key, value in flags.items(): if isinstance(value, FeatureFlag): continue - flags[key] = _parse_record(key, value) + flags[key] = _parse_record(FeatureFlag, key, value) else: # Handle legacy format featureFlags = resp.get("featureFlags", {}) From 63975e65430adcaf145d94e046a51cfc20eeb0a3 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Mon, 2 Nov 2026 19:48:45 +0100 Subject: [PATCH 32/35] perf(flags): keep one copy of each remotely evaluated flag in a snapshot A snapshot kept each remotely evaluated flag twice: a flattened record and the parsed /flags record, with the payload both as its JSON text and decoded. The snapshot record now reads its fields from the parsed record and decodes the payload on its first read, then keeps it. Private record details that equal their defaults are no longer stored on each instance. Retained memory of evaluate_flags() for a /flags?v=4 response of 5,000 boolean flags (tracemalloc, Python 3.14, including JSON decoding): no payloads: 3.64 MiB -> 2.87 MiB 1 KiB JSON payload each: 28.10 MiB -> 7.95 MiB A payload that is read is decoded and kept as before, so reading every payload retains about what it did before this change. --- posthog/async_client.py | 24 +------- posthog/client.py | 34 ++---------- posthog/feature_flag_evaluations.py | 76 +++++++++++++++++++++++--- posthog/test/test_flags_v4_response.py | 17 ++++++ posthog/types.py | 4 +- 5 files changed, 95 insertions(+), 60 deletions(-) diff --git a/posthog/async_client.py b/posthog/async_client.py index 98d265d26..72f7cd2fe 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -40,8 +40,6 @@ MAX_DICT_SIZE as _MAX_DICT_SIZE, _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES, Client as _SyncClient, - _metadata_has_experiment, - _parse_flag_payload, add_context_tags as _add_context_tags, get_identity_state as _get_identity_state, stringify_id as _stringify_id, @@ -74,12 +72,12 @@ from .feature_flag_evaluations import ( FeatureFlagEvaluations, _EvaluatedFlagRecord, + _RemoteFlagRecord, _FeatureFlagEvaluationsHost, ) from .release_id import _resolve_release_id from .request import QuotaLimitError, determine_server_host, normalize_host from .types import ( - FlagMetadata, FlagValue, _has_untyped_flag_records, _UNTYPED_FLAGS_RESPONSE_MESSAGE, @@ -1017,25 +1015,7 @@ async def evaluate_flags( for key, detail in response.get("flags", {}).items(): if requested_keys is not None and key not in requested_keys: continue - metadata = detail.metadata - records[key] = _EvaluatedFlagRecord( - key=key, - enabled=detail.enabled, - variant=detail.variant, - payload=_parse_flag_payload(metadata.payload), - id=metadata.id if isinstance(metadata, FlagMetadata) else None, - version=( - metadata.version if isinstance(metadata, FlagMetadata) else None - ), - reason=( - detail.reason.description - if detail.reason and detail.reason.description - else None - ), - locally_evaluated=False, - has_experiment=_metadata_has_experiment(metadata), - details=detail, - ) + records[key] = _RemoteFlagRecord(detail) except QuotaLimitError: self.log.warning( "[FEATURE FLAGS] Feature flag evaluation was quota limited" diff --git a/posthog/client.py b/posthog/client.py index 77b724d9f..4cc2ac914 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -77,7 +77,9 @@ ) from posthog.feature_flag_evaluations import ( FeatureFlagEvaluations, + _LocalFlagRecord, _EvaluatedFlagRecord, + _RemoteFlagRecord, _FeatureFlagEvaluationsHost, ) from posthog.feature_flags import ( @@ -5010,7 +5012,7 @@ def evaluate_flags( for key, value in local_flags.items(): flag_def = feature_flags_by_key.get(key) or {} - records[key] = _EvaluatedFlagRecord( + records[key] = _LocalFlagRecord( key=key, enabled=value is not False, variant=value if isinstance(value, str) else None, @@ -5053,35 +5055,7 @@ def evaluate_flags( continue if key in locally_evaluated_keys: continue - payload = _parse_flag_payload( - detail.metadata.payload - if isinstance(detail.metadata, FlagMetadata) - else getattr(detail.metadata, "payload", None) - ) - records[key] = _EvaluatedFlagRecord( - key=key, - enabled=detail.enabled, - variant=detail.variant, - payload=payload, - id=( - detail.metadata.id - if isinstance(detail.metadata, FlagMetadata) - else None - ), - version=( - detail.metadata.version - if isinstance(detail.metadata, FlagMetadata) - else None - ), - reason=( - detail.reason.description - if detail.reason and detail.reason.description - else None - ), - locally_evaluated=False, - has_experiment=_metadata_has_experiment(detail.metadata), - details=detail, - ) + records[key] = _RemoteFlagRecord(detail) except QuotaLimitError as e: self.log.warning(f"[FEATURE FLAGS] Quota limit exceeded: {e}") quota_limited = True diff --git a/posthog/feature_flag_evaluations.py b/posthog/feature_flag_evaluations.py index a68906ff9..28e5bf31e 100644 --- a/posthog/feature_flag_evaluations.py +++ b/posthog/feature_flag_evaluations.py @@ -9,13 +9,14 @@ from types import MappingProxyType from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Union -from posthog.types import FeatureFlag as _FeatureFlag -from posthog.types import FlagValue, UnresolvedFlagReason +from .types import FeatureFlag as _FeatureFlag +from .types import FlagMetadata as _FlagMetadata +from .types import FlagValue, UnresolvedFlagReason, _parse_flag_payload @dataclass(frozen=True) -class _EvaluatedFlagRecord: - """Internal per-flag record stored by a FeatureFlagEvaluations instance.""" +class _LocalFlagRecord: + """Internal per-flag record of a locally evaluated flag.""" key: str enabled: bool @@ -28,9 +29,70 @@ class _EvaluatedFlagRecord: # Server-reported signal for whether the flag is linked to an experiment. # ``None`` when the server did not report it (older deployments). has_experiment: Optional[bool] = None - # The parsed /flags record, with its typed value, reason and metadata. - # ``None`` for a locally evaluated flag. - details: Optional[_FeatureFlag] = None + + +_UNDECODED: Any = object() + + +class _RemoteFlagRecord: + """Internal per-flag record of a remotely evaluated flag. + + The parsed ``/flags`` record is the only copy of the flag: the record fields are + read from it, and the payload is decoded on its first read and then kept. + """ + + __slots__ = ("details", "_payload") + + locally_evaluated = False + + def __init__(self, details: _FeatureFlag) -> None: + # The parsed /flags record, with its typed value, reason and metadata. + self.details = details + self._payload: Any = _UNDECODED + + @property + def key(self) -> str: + return self.details.key + + @property + def enabled(self) -> bool: + return self.details.enabled + + @property + def variant(self) -> Optional[str]: + return self.details.variant + + @property + def payload(self) -> Optional[Any]: + if self._payload is _UNDECODED: + self._payload = _parse_flag_payload( + getattr(self.details.metadata, "payload", None) + ) + return self._payload + + @property + def id(self) -> Optional[int]: + metadata = self.details.metadata + return metadata.id if isinstance(metadata, _FlagMetadata) else None + + @property + def version(self) -> Optional[int]: + metadata = self.details.metadata + return metadata.version if isinstance(metadata, _FlagMetadata) else None + + @property + def reason(self) -> Optional[str]: + reason = self.details.reason + return reason.description if reason and reason.description else None + + @property + def has_experiment(self) -> Optional[bool]: + metadata = self.details.metadata + return metadata.has_experiment if isinstance(metadata, _FlagMetadata) else None + + +# The per-flag record stored by a FeatureFlagEvaluations instance. +_EvaluatedFlagRecord = Union[_LocalFlagRecord, _RemoteFlagRecord] @dataclass diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py index cdbaa5a99..662011dce 100644 --- a/posthog/test/test_flags_v4_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -920,6 +920,23 @@ def test_typed_values_render_the_corpus_legacy_cells_from_both_shapes(case): assert result.payload == legacy["payload"] +def test_a_snapshot_reads_its_flags_from_the_parsed_records(client): + response = _typed_response({"object-flag": {"layout": "compact"}, "off": False}) + response["flags"]["off"]["metadata"]["payload"] = "not json" + snapshot = _evaluate(client, response) + + record = snapshot._flags["object-flag"] + assert (record.key, record.enabled, record.variant) == ("object-flag", True, None) + assert (record.id, record.version, record.reason) == (300, 2, "Matched rule 1") + assert record.has_experiment is False + assert record.locally_evaluated is False + # The payload is decoded once and the same object is returned on every read. + payload = snapshot.get_flag_payload("object-flag") + assert payload == {"layout": "compact"} + assert snapshot.get_flag_payload("object-flag") is payload + assert snapshot.get_flag_payload("off") is None + + def test_number_and_object_payloads_are_compact_json_in_wire_order(): response = _typed_response( {"number": 12.5, "object": {"b": "ü", "a": [1, True, None]}} diff --git a/posthog/types.py b/posthog/types.py index 5250c234b..85938a939 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -231,7 +231,9 @@ def from_json(cls, resp: Any) -> Union["FlagMetadata", LegacyFlagMetadata]: def _set_private_fields(obj: _T, **fields: Any) -> _T: """Set the private, work-in-progress attributes of a frozen dataclass.""" for name, value in fields.items(): - object.__setattr__(obj, name, value) + # A value that is already the default is not stored on the instance. + if getattr(obj, name) is not value: + object.__setattr__(obj, name, value) return obj From 77b03547f1c34ae0a106079ba8c4f14a42fd348f Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Mon, 2 Nov 2026 19:54:12 +0100 Subject: [PATCH 33/35] test(flags): pin legacy flag results to those 7.82.1 recorded The parity tests compare this SDK's v2 and v4 readers with each other, so a change shared by both passes. These tests compare both with results recorded once with posthog 7.82.1 for the same flags: the legacy getters, get_flags_decision, snapshot reads, $feature_flag_called properties and fallback-cache histories. Where a result differs from 7.82.1 by decision, the test says why: an absent flag ID or version reads as None, not 0, and a record with a field of the wrong type fails only itself. --- .../test/fixtures/released_flags_7_82_1.json | 2258 +++++++++++++++++ posthog/test/released_flags_scenarios.py | 319 +++ posthog/test/test_flags_released_behaviour.py | 68 + 3 files changed, 2645 insertions(+) create mode 100644 posthog/test/fixtures/released_flags_7_82_1.json create mode 100644 posthog/test/released_flags_scenarios.py create mode 100644 posthog/test/test_flags_released_behaviour.py diff --git a/posthog/test/fixtures/released_flags_7_82_1.json b/posthog/test/fixtures/released_flags_7_82_1.json new file mode 100644 index 000000000..627f5336e --- /dev/null +++ b/posthog/test/fixtures/released_flags_7_82_1.json @@ -0,0 +1,2258 @@ +{ + "description": "Results of the scenarios in posthog/test/released_flags_scenarios.py, recorded with posthog 7.82.1 from the v2 response shape. See that module to regenerate.", + "results": { + "cache histories": { + "good, disabled, timeout": { + "events": [ + [ + "$feature_flag_called", + { + "$feature/cached": "blue", + "$feature_flag": "cached", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_payload": { + "theme": "dark" + }, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": "blue", + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/cached": false, + "$feature_flag": "cached", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": false, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/cached": false, + "$feature_flag": "cached", + "$feature_flag_error": "timeout", + "$feature_flag_response": false, + "locally_evaluated": false + } + ] + ], + "results": [ + [ + true, + "blue", + { + "theme": "dark" + }, + "Matched condition set 1" + ], + [ + false, + null, + null, + "Matched condition set 1" + ], + [ + false, + null, + null, + "Matched condition set 1" + ] + ] + }, + "good, each request failure": { + "events": [ + [ + "$feature_flag_called", + { + "$feature/cached": "blue", + "$feature_flag": "cached", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_payload": { + "theme": "dark" + }, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": "blue", + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/cached": "blue", + "$feature_flag": "cached", + "$feature_flag_error": "timeout", + "$feature_flag_payload": { + "theme": "dark" + }, + "$feature_flag_response": "blue", + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/cached": "blue", + "$feature_flag": "cached", + "$feature_flag_error": "connection_error", + "$feature_flag_payload": { + "theme": "dark" + }, + "$feature_flag_response": "blue", + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/cached": "blue", + "$feature_flag": "cached", + "$feature_flag_error": "api_error_500", + "$feature_flag_payload": { + "theme": "dark" + }, + "$feature_flag_response": "blue", + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/cached": "blue", + "$feature_flag": "cached", + "$feature_flag_error": "quota_limited", + "$feature_flag_payload": { + "theme": "dark" + }, + "$feature_flag_response": "blue", + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/cached": "blue", + "$feature_flag": "cached", + "$feature_flag_error": "unknown_error", + "$feature_flag_payload": { + "theme": "dark" + }, + "$feature_flag_response": "blue", + "locally_evaluated": false + } + ] + ], + "results": [ + [ + true, + "blue", + { + "theme": "dark" + }, + "Matched condition set 1" + ], + [ + true, + "blue", + { + "theme": "dark" + }, + "Matched condition set 1" + ], + [ + true, + "blue", + { + "theme": "dark" + }, + "Matched condition set 1" + ], + [ + true, + "blue", + { + "theme": "dark" + }, + "Matched condition set 1" + ], + [ + true, + "blue", + { + "theme": "dark" + }, + "Matched condition set 1" + ], + [ + true, + "blue", + { + "theme": "dark" + }, + "Matched condition set 1" + ] + ] + }, + "good, missing with errors, timeout": { + "events": [ + [ + "$feature_flag_called", + { + "$feature/cached": "blue", + "$feature_flag": "cached", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_payload": { + "theme": "dark" + }, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": "blue", + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/cached": null, + "$feature_flag": "cached", + "$feature_flag_error": "errors_while_computing_flags,flag_missing", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": null, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/cached": "blue", + "$feature_flag": "cached", + "$feature_flag_error": "timeout", + "$feature_flag_payload": { + "theme": "dark" + }, + "$feature_flag_response": "blue", + "locally_evaluated": false + } + ] + ], + "results": [ + [ + true, + "blue", + { + "theme": "dark" + }, + "Matched condition set 1" + ], + null, + [ + true, + "blue", + { + "theme": "dark" + }, + "Matched condition set 1" + ] + ] + }, + "good, other variant, timeout": { + "events": [ + [ + "$feature_flag_called", + { + "$feature/cached": "blue", + "$feature_flag": "cached", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_payload": { + "theme": "dark" + }, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": "blue", + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/cached": "green", + "$feature_flag": "cached", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": "green", + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/cached": "green", + "$feature_flag": "cached", + "$feature_flag_error": "timeout", + "$feature_flag_response": "green", + "locally_evaluated": false + } + ] + ], + "results": [ + [ + true, + "blue", + { + "theme": "dark" + }, + "Matched condition set 1" + ], + [ + true, + "green", + null, + "Matched condition set 1" + ], + [ + true, + "green", + null, + "Matched condition set 1" + ] + ] + }, + "good, server failed, timeout": { + "events": [ + [ + "$feature_flag_called", + { + "$feature/cached": "blue", + "$feature_flag": "cached", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_payload": { + "theme": "dark" + }, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": "blue", + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/cached": false, + "$feature_flag": "cached", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_payload": { + "theme": "dark" + }, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": false, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/cached": false, + "$feature_flag": "cached", + "$feature_flag_error": "timeout", + "$feature_flag_payload": { + "theme": "dark" + }, + "$feature_flag_response": false, + "locally_evaluated": false + } + ] + ], + "results": [ + [ + true, + "blue", + { + "theme": "dark" + }, + "Matched condition set 1" + ], + [ + false, + null, + { + "theme": "dark" + }, + "Matched condition set 1" + ], + [ + false, + null, + { + "theme": "dark" + }, + "Matched condition set 1" + ] + ] + }, + "good, unreadable record, timeout": { + "events": [ + [ + "$feature_flag_called", + { + "$feature/cached": "blue", + "$feature_flag": "cached", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_payload": { + "theme": "dark" + }, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": "blue", + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/cached": "blue", + "$feature_flag": "cached", + "$feature_flag_error": "unknown_error", + "$feature_flag_payload": { + "theme": "dark" + }, + "$feature_flag_response": "blue", + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/cached": "blue", + "$feature_flag": "cached", + "$feature_flag_error": "timeout", + "$feature_flag_payload": { + "theme": "dark" + }, + "$feature_flag_response": "blue", + "locally_evaluated": false + } + ] + ], + "results": [ + [ + true, + "blue", + { + "theme": "dark" + }, + "Matched condition set 1" + ], + [ + true, + "blue", + { + "theme": "dark" + }, + "Matched condition set 1" + ], + [ + true, + "blue", + { + "theme": "dark" + }, + "Matched condition set 1" + ] + ] + } + }, + "getters": { + "events": [ + [ + "$feature_flag_called", + { + "$feature/bool-on": true, + "$feature_flag": "bool-on", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": true, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/bool-on": true, + "$feature_flag": "bool-on", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": true, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/bool-on": true, + "$feature_flag": "bool-on", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": true, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/bool-off": false, + "$feature_flag": "bool-off", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": false, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/bool-off": false, + "$feature_flag": "bool-off", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": false, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/bool-off": false, + "$feature_flag": "bool-off", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": false, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v1-variant": "control", + "$feature_flag": "v1-variant", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": true, + "$feature_flag_id": 701, + "$feature_flag_payload": { + "columns": 2 + }, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": "control", + "$feature_flag_version": 4, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v1-variant": "control", + "$feature_flag": "v1-variant", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": true, + "$feature_flag_id": 701, + "$feature_flag_payload": { + "columns": 2 + }, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": "control", + "$feature_flag_version": 4, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v1-variant": "control", + "$feature_flag": "v1-variant", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": true, + "$feature_flag_id": 701, + "$feature_flag_payload": { + "columns": 2 + }, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": "control", + "$feature_flag_version": 4, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v1-bool-payload": true, + "$feature_flag": "v1-bool-payload", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_payload": { + "cta": "Try it", + "n": 1 + }, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": true, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v1-bool-payload": true, + "$feature_flag": "v1-bool-payload", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_payload": { + "cta": "Try it", + "n": 1 + }, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": true, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v1-bool-payload": true, + "$feature_flag": "v1-bool-payload", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_payload": { + "cta": "Try it", + "n": 1 + }, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": true, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v1-string-payload": true, + "$feature_flag": "v1-string-payload", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_payload": "plain", + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": true, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v1-string-payload": true, + "$feature_flag": "v1-string-payload", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_payload": "plain", + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": true, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v1-string-payload": true, + "$feature_flag": "v1-string-payload", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_payload": "plain", + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": true, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v2-string": "compact", + "$feature_flag": "v2-string", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": "compact", + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v2-string": "compact", + "$feature_flag": "v2-string", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": "compact", + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v2-string": "compact", + "$feature_flag": "v2-string", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": "compact", + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v2-null": false, + "$feature_flag": "v2-null", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": false, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v2-null": false, + "$feature_flag": "v2-null", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": false, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v2-null": false, + "$feature_flag": "v2-null", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": false, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v2-number": true, + "$feature_flag": "v2-number", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_payload": 12.5, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": true, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v2-number": true, + "$feature_flag": "v2-number", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_payload": 12.5, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": true, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v2-number": true, + "$feature_flag": "v2-number", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_payload": 12.5, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": true, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v2-object": true, + "$feature_flag": "v2-object", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_payload": { + "layout": "compact", + "n": [ + 1, + true + ] + }, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": true, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v2-object": true, + "$feature_flag": "v2-object", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_payload": { + "layout": "compact", + "n": [ + 1, + true + ] + }, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": true, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v2-object": true, + "$feature_flag": "v2-object", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_payload": { + "layout": "compact", + "n": [ + 1, + true + ] + }, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": true, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/sparse": "compact", + "$feature_flag": "sparse", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": "compact", + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/sparse": "compact", + "$feature_flag": "sparse", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": "compact", + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/sparse": "compact", + "$feature_flag": "sparse", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": "compact", + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/server-failed": false, + "$feature_flag": "server-failed", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": false, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/server-failed": false, + "$feature_flag": "server-failed", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": false, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/server-failed": false, + "$feature_flag": "server-failed", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": false, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/missing": null, + "$feature_flag": "missing", + "$feature_flag_error": "flag_missing", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": null, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/missing": null, + "$feature_flag": "missing", + "$feature_flag_error": "flag_missing", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": null, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/missing": null, + "$feature_flag": "missing", + "$feature_flag_error": "flag_missing", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": null, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/bool-on": true, + "$feature_flag": "bool-on", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": true, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/bool-off": false, + "$feature_flag": "bool-off", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": false, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v1-variant": "control", + "$feature_flag": "v1-variant", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": true, + "$feature_flag_id": 701, + "$feature_flag_payload": { + "columns": 2 + }, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": "control", + "$feature_flag_version": 4, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v1-bool-payload": true, + "$feature_flag": "v1-bool-payload", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_payload": { + "cta": "Try it", + "n": 1 + }, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": true, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v1-string-payload": true, + "$feature_flag": "v1-string-payload", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_payload": "plain", + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": true, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v2-string": "compact", + "$feature_flag": "v2-string", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": "compact", + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v2-null": false, + "$feature_flag": "v2-null", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": false, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v2-number": true, + "$feature_flag": "v2-number", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_payload": 12.5, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": true, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v2-object": true, + "$feature_flag": "v2-object", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_payload": { + "layout": "compact", + "n": [ + 1, + true + ] + }, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": true, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/sparse": "compact", + "$feature_flag": "sparse", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": "compact", + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/server-failed": false, + "$feature_flag": "server-failed", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": false, + "$feature_flag_id": 700, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": false, + "$feature_flag_version": 3, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/missing": null, + "$feature_flag": "missing", + "$feature_flag_error": "flag_missing", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": null, + "locally_evaluated": false + } + ], + [ + "snapshot-event", + { + "$active_feature_flags": [ + "bool-on", + "sparse", + "v1-bool-payload", + "v1-string-payload", + "v1-variant", + "v2-number", + "v2-object", + "v2-string" + ], + "$feature/bool-off": false, + "$feature/bool-on": true, + "$feature/server-failed": false, + "$feature/sparse": "compact", + "$feature/v1-bool-payload": true, + "$feature/v1-string-payload": true, + "$feature/v1-variant": "control", + "$feature/v2-null": false, + "$feature/v2-number": true, + "$feature/v2-object": true, + "$feature/v2-string": "compact" + } + ], + [ + "fetched-event", + { + "$active_feature_flags": [ + "bool-on", + "v1-variant", + "v1-bool-payload", + "v1-string-payload", + "v2-string", + "v2-number", + "v2-object", + "sparse" + ], + "$feature/bool-off": false, + "$feature/bool-on": true, + "$feature/server-failed": false, + "$feature/sparse": "compact", + "$feature/v1-bool-payload": true, + "$feature/v1-string-payload": true, + "$feature/v1-variant": "control", + "$feature/v2-null": false, + "$feature/v2-number": true, + "$feature/v2-object": true, + "$feature/v2-string": "compact" + } + ] + ], + "feature_enabled:bool-off": false, + "feature_enabled:bool-on": true, + "feature_enabled:missing": null, + "feature_enabled:server-failed": false, + "feature_enabled:sparse": true, + "feature_enabled:v1-bool-payload": true, + "feature_enabled:v1-string-payload": true, + "feature_enabled:v1-variant": true, + "feature_enabled:v2-null": false, + "feature_enabled:v2-number": true, + "feature_enabled:v2-object": true, + "feature_enabled:v2-string": true, + "get_all_flags": { + "bool-off": false, + "bool-on": true, + "server-failed": false, + "sparse": "compact", + "v1-bool-payload": true, + "v1-string-payload": true, + "v1-variant": "control", + "v2-null": false, + "v2-number": true, + "v2-object": true, + "v2-string": "compact" + }, + "get_all_flags_and_payloads": { + "featureFlagPayloads": { + "v1-bool-payload": "{\"cta\": \"Try it\", \"n\": 1}", + "v1-string-payload": "\"plain\"", + "v1-variant": "{\"columns\": 2}", + "v2-number": "12.5", + "v2-object": "{\"layout\":\"compact\",\"n\":[1,true]}" + }, + "featureFlags": { + "bool-off": false, + "bool-on": true, + "server-failed": false, + "sparse": "compact", + "v1-bool-payload": true, + "v1-string-payload": true, + "v1-variant": "control", + "v2-null": false, + "v2-number": true, + "v2-object": true, + "v2-string": "compact" + } + }, + "get_feature_flag:bool-off": false, + "get_feature_flag:bool-on": true, + "get_feature_flag:missing": null, + "get_feature_flag:server-failed": false, + "get_feature_flag:sparse": "compact", + "get_feature_flag:v1-bool-payload": true, + "get_feature_flag:v1-string-payload": true, + "get_feature_flag:v1-variant": "control", + "get_feature_flag:v2-null": false, + "get_feature_flag:v2-number": true, + "get_feature_flag:v2-object": true, + "get_feature_flag:v2-string": "compact", + "get_feature_flag_payload:bool-off": null, + "get_feature_flag_payload:bool-on": null, + "get_feature_flag_payload:missing": null, + "get_feature_flag_payload:server-failed": null, + "get_feature_flag_payload:sparse": null, + "get_feature_flag_payload:v1-bool-payload": { + "cta": "Try it", + "n": 1 + }, + "get_feature_flag_payload:v1-string-payload": "plain", + "get_feature_flag_payload:v1-variant": { + "columns": 2 + }, + "get_feature_flag_payload:v2-null": null, + "get_feature_flag_payload:v2-number": 12.5, + "get_feature_flag_payload:v2-object": { + "layout": "compact", + "n": [ + 1, + true + ] + }, + "get_feature_flag_payload:v2-string": null, + "get_feature_flag_result:bool-off": [ + false, + null, + null, + "" + ], + "get_feature_flag_result:bool-on": [ + true, + null, + null, + "Matched condition set 1" + ], + "get_feature_flag_result:missing": null, + "get_feature_flag_result:server-failed": [ + false, + null, + null, + "Matched condition set 1" + ], + "get_feature_flag_result:sparse": [ + true, + "compact", + null, + null + ], + "get_feature_flag_result:v1-bool-payload": [ + true, + null, + { + "cta": "Try it", + "n": 1 + }, + "Matched condition set 1" + ], + "get_feature_flag_result:v1-string-payload": [ + true, + null, + "plain", + "Matched condition set 1" + ], + "get_feature_flag_result:v1-variant": [ + true, + "control", + { + "columns": 2 + }, + "Matched condition set 1" + ], + "get_feature_flag_result:v2-null": [ + false, + null, + null, + "Matched condition set 1" + ], + "get_feature_flag_result:v2-number": [ + true, + null, + 12.5, + "Matched condition set 1" + ], + "get_feature_flag_result:v2-object": [ + true, + null, + { + "layout": "compact", + "n": [ + 1, + true + ] + }, + "Matched condition set 1" + ], + "get_feature_flag_result:v2-string": [ + true, + "compact", + null, + "Matched condition set 1" + ], + "get_feature_flags_and_payloads": { + "featureFlagPayloads": { + "v1-bool-payload": "{\"cta\": \"Try it\", \"n\": 1}", + "v1-string-payload": "\"plain\"", + "v1-variant": "{\"columns\": 2}", + "v2-number": "12.5", + "v2-object": "{\"layout\":\"compact\",\"n\":[1,true]}" + }, + "featureFlags": { + "bool-off": false, + "bool-on": true, + "server-failed": false, + "sparse": "compact", + "v1-bool-payload": true, + "v1-string-payload": true, + "v1-variant": "control", + "v2-null": false, + "v2-number": true, + "v2-object": true, + "v2-string": "compact" + } + }, + "get_feature_payloads": { + "v1-bool-payload": "{\"cta\": \"Try it\", \"n\": 1}", + "v1-string-payload": "\"plain\"", + "v1-variant": "{\"columns\": 2}", + "v2-number": "12.5", + "v2-object": "{\"layout\":\"compact\",\"n\":[1,true]}" + }, + "get_feature_variants": { + "bool-off": false, + "bool-on": true, + "server-failed": false, + "sparse": "compact", + "v1-bool-payload": true, + "v1-string-payload": true, + "v1-variant": "control", + "v2-null": false, + "v2-number": true, + "v2-object": true, + "v2-string": "compact" + }, + "get_flags_decision": { + "errorsWhileComputingFlags": false, + "evaluatedAt": 1800000000000, + "flags": { + "bool-off": { + "__class__": "FeatureFlag", + "enabled": false, + "key": "bool-off", + "metadata": { + "__class__": "FlagMetadata", + "description": null, + "has_experiment": false, + "id": 700, + "payload": null, + "version": 3 + }, + "reason": { + "__class__": "FlagReason", + "code": "no_condition_match", + "condition_index": null, + "description": "" + }, + "variant": null + }, + "bool-on": { + "__class__": "FeatureFlag", + "enabled": true, + "key": "bool-on", + "metadata": { + "__class__": "FlagMetadata", + "description": null, + "has_experiment": false, + "id": 700, + "payload": null, + "version": 3 + }, + "reason": { + "__class__": "FlagReason", + "code": "condition_match", + "condition_index": 0, + "description": "Matched condition set 1" + }, + "variant": null + }, + "server-failed": { + "__class__": "FeatureFlag", + "enabled": false, + "key": "server-failed", + "metadata": { + "__class__": "FlagMetadata", + "description": null, + "has_experiment": false, + "id": 700, + "payload": null, + "version": 3 + }, + "reason": { + "__class__": "FlagReason", + "code": "condition_match", + "condition_index": 0, + "description": "Matched condition set 1" + }, + "variant": null + }, + "sparse": { + "__class__": "FeatureFlag", + "enabled": true, + "key": "sparse", + "metadata": { + "__class__": "FlagMetadata", + "description": "", + "has_experiment": null, + "id": 0, + "payload": null, + "version": 0 + }, + "reason": null, + "variant": "compact" + }, + "v1-bool-payload": { + "__class__": "FeatureFlag", + "enabled": true, + "key": "v1-bool-payload", + "metadata": { + "__class__": "FlagMetadata", + "description": null, + "has_experiment": false, + "id": 700, + "payload": "{\"cta\": \"Try it\", \"n\": 1}", + "version": 3 + }, + "reason": { + "__class__": "FlagReason", + "code": "condition_match", + "condition_index": 0, + "description": "Matched condition set 1" + }, + "variant": null + }, + "v1-string-payload": { + "__class__": "FeatureFlag", + "enabled": true, + "key": "v1-string-payload", + "metadata": { + "__class__": "FlagMetadata", + "description": null, + "has_experiment": false, + "id": 700, + "payload": "\"plain\"", + "version": 3 + }, + "reason": { + "__class__": "FlagReason", + "code": "condition_match", + "condition_index": 0, + "description": "Matched condition set 1" + }, + "variant": null + }, + "v1-variant": { + "__class__": "FeatureFlag", + "enabled": true, + "key": "v1-variant", + "metadata": { + "__class__": "FlagMetadata", + "description": "A variant flag", + "has_experiment": true, + "id": 701, + "payload": "{\"columns\": 2}", + "version": 4 + }, + "reason": { + "__class__": "FlagReason", + "code": "condition_match", + "condition_index": 0, + "description": "Matched condition set 1" + }, + "variant": "control" + }, + "v2-null": { + "__class__": "FeatureFlag", + "enabled": false, + "key": "v2-null", + "metadata": { + "__class__": "FlagMetadata", + "description": null, + "has_experiment": false, + "id": 700, + "payload": null, + "version": 3 + }, + "reason": { + "__class__": "FlagReason", + "code": "condition_match", + "condition_index": 0, + "description": "Matched condition set 1" + }, + "variant": null + }, + "v2-number": { + "__class__": "FeatureFlag", + "enabled": true, + "key": "v2-number", + "metadata": { + "__class__": "FlagMetadata", + "description": null, + "has_experiment": false, + "id": 700, + "payload": "12.5", + "version": 3 + }, + "reason": { + "__class__": "FlagReason", + "code": "condition_match", + "condition_index": 0, + "description": "Matched condition set 1" + }, + "variant": null + }, + "v2-object": { + "__class__": "FeatureFlag", + "enabled": true, + "key": "v2-object", + "metadata": { + "__class__": "FlagMetadata", + "description": null, + "has_experiment": false, + "id": 700, + "payload": "{\"layout\":\"compact\",\"n\":[1,true]}", + "version": 3 + }, + "reason": { + "__class__": "FlagReason", + "code": "condition_match", + "condition_index": 0, + "description": "Matched condition set 1" + }, + "variant": null + }, + "v2-string": { + "__class__": "FeatureFlag", + "enabled": true, + "key": "v2-string", + "metadata": { + "__class__": "FlagMetadata", + "description": null, + "has_experiment": false, + "id": 700, + "payload": null, + "version": 3 + }, + "reason": { + "__class__": "FlagReason", + "code": "condition_match", + "condition_index": 0, + "description": "Matched condition set 1" + }, + "variant": "compact" + } + }, + "requestId": "60000000-0000-4000-8000-000000000000" + }, + "snapshot.get_flag:bool-off": false, + "snapshot.get_flag:bool-on": true, + "snapshot.get_flag:missing": null, + "snapshot.get_flag:server-failed": false, + "snapshot.get_flag:sparse": "compact", + "snapshot.get_flag:v1-bool-payload": true, + "snapshot.get_flag:v1-string-payload": true, + "snapshot.get_flag:v1-variant": "control", + "snapshot.get_flag:v2-null": false, + "snapshot.get_flag:v2-number": true, + "snapshot.get_flag:v2-object": true, + "snapshot.get_flag:v2-string": "compact", + "snapshot.get_flag_payload:bool-off": null, + "snapshot.get_flag_payload:bool-on": null, + "snapshot.get_flag_payload:missing": null, + "snapshot.get_flag_payload:server-failed": null, + "snapshot.get_flag_payload:sparse": null, + "snapshot.get_flag_payload:v1-bool-payload": { + "cta": "Try it", + "n": 1 + }, + "snapshot.get_flag_payload:v1-string-payload": "plain", + "snapshot.get_flag_payload:v1-variant": { + "columns": 2 + }, + "snapshot.get_flag_payload:v2-null": null, + "snapshot.get_flag_payload:v2-number": 12.5, + "snapshot.get_flag_payload:v2-object": { + "layout": "compact", + "n": [ + 1, + true + ] + }, + "snapshot.get_flag_payload:v2-string": null, + "snapshot.is_enabled:bool-off": false, + "snapshot.is_enabled:bool-on": true, + "snapshot.is_enabled:missing": false, + "snapshot.is_enabled:server-failed": false, + "snapshot.is_enabled:sparse": true, + "snapshot.is_enabled:v1-bool-payload": true, + "snapshot.is_enabled:v1-string-payload": true, + "snapshot.is_enabled:v1-variant": true, + "snapshot.is_enabled:v2-null": false, + "snapshot.is_enabled:v2-number": true, + "snapshot.is_enabled:v2-object": true, + "snapshot.is_enabled:v2-string": true, + "snapshot.keys": [ + "bool-off", + "bool-on", + "server-failed", + "sparse", + "v1-bool-payload", + "v1-string-payload", + "v1-variant", + "v2-null", + "v2-number", + "v2-object", + "v2-string" + ] + }, + "getters with an unreadable record": { + "events": [ + [ + "$feature_flag_called", + { + "$feature/v1-variant": null, + "$feature_flag": "v1-variant", + "$feature_flag_error": "unknown_error", + "$feature_flag_response": null, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v1-variant": null, + "$feature_flag": "v1-variant", + "$feature_flag_error": "unknown_error", + "$feature_flag_response": null, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v1-variant": null, + "$feature_flag": "v1-variant", + "$feature_flag_error": "unknown_error", + "$feature_flag_response": null, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/unreadable": null, + "$feature_flag": "unreadable", + "$feature_flag_error": "unknown_error", + "$feature_flag_response": null, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/unreadable": null, + "$feature_flag": "unreadable", + "$feature_flag_error": "unknown_error", + "$feature_flag_response": null, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/unreadable": null, + "$feature_flag": "unreadable", + "$feature_flag_error": "unknown_error", + "$feature_flag_response": null, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/missing": null, + "$feature_flag": "missing", + "$feature_flag_error": "unknown_error", + "$feature_flag_response": null, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/missing": null, + "$feature_flag": "missing", + "$feature_flag_error": "unknown_error", + "$feature_flag_response": null, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/missing": null, + "$feature_flag": "missing", + "$feature_flag_error": "unknown_error", + "$feature_flag_response": null, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v1-variant": null, + "$feature_flag": "v1-variant", + "$feature_flag_error": "flag_missing", + "$feature_flag_response": null, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/unreadable": null, + "$feature_flag": "unreadable", + "$feature_flag_error": "flag_missing", + "$feature_flag_response": null, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/missing": null, + "$feature_flag": "missing", + "$feature_flag_error": "flag_missing", + "$feature_flag_response": null, + "locally_evaluated": false + } + ], + [ + "snapshot-event", + {} + ], + [ + "fetched-event", + {} + ] + ], + "feature_enabled:missing": null, + "feature_enabled:unreadable": null, + "feature_enabled:v1-variant": null, + "get_all_flags": {}, + "get_all_flags_and_payloads": { + "featureFlagPayloads": {}, + "featureFlags": {} + }, + "get_feature_flag:missing": null, + "get_feature_flag:unreadable": null, + "get_feature_flag:v1-variant": null, + "get_feature_flag_payload:missing": null, + "get_feature_flag_payload:unreadable": null, + "get_feature_flag_payload:v1-variant": null, + "get_feature_flag_result:missing": null, + "get_feature_flag_result:unreadable": null, + "get_feature_flag_result:v1-variant": null, + "get_feature_flags_and_payloads": { + "featureFlagPayloads": {}, + "featureFlags": {} + }, + "get_feature_payloads": {}, + "get_feature_variants": {}, + "get_flags_decision": { + "flags": {}, + "requestId": null + }, + "snapshot.get_flag:missing": null, + "snapshot.get_flag:unreadable": null, + "snapshot.get_flag:v1-variant": null, + "snapshot.get_flag_payload:missing": null, + "snapshot.get_flag_payload:unreadable": null, + "snapshot.get_flag_payload:v1-variant": null, + "snapshot.is_enabled:missing": false, + "snapshot.is_enabled:unreadable": false, + "snapshot.is_enabled:v1-variant": false, + "snapshot.keys": [] + }, + "getters with the unreadable record read as disabled": { + "events": [ + [ + "$feature_flag_called", + { + "$feature/v1-variant": "control", + "$feature_flag": "v1-variant", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": true, + "$feature_flag_id": 701, + "$feature_flag_payload": { + "columns": 2 + }, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": "control", + "$feature_flag_version": 4, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v1-variant": "control", + "$feature_flag": "v1-variant", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": true, + "$feature_flag_id": 701, + "$feature_flag_payload": { + "columns": 2 + }, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": "control", + "$feature_flag_version": 4, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v1-variant": "control", + "$feature_flag": "v1-variant", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": true, + "$feature_flag_id": 701, + "$feature_flag_payload": { + "columns": 2 + }, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": "control", + "$feature_flag_version": 4, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/unreadable": false, + "$feature_flag": "unreadable", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": false, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/unreadable": false, + "$feature_flag": "unreadable", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": false, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/unreadable": false, + "$feature_flag": "unreadable", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": false, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/missing": null, + "$feature_flag": "missing", + "$feature_flag_error": "flag_missing", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": null, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/missing": null, + "$feature_flag": "missing", + "$feature_flag_error": "flag_missing", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": null, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/missing": null, + "$feature_flag": "missing", + "$feature_flag_error": "flag_missing", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": null, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/v1-variant": "control", + "$feature_flag": "v1-variant", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_has_experiment": true, + "$feature_flag_id": 701, + "$feature_flag_payload": { + "columns": 2 + }, + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": "control", + "$feature_flag_version": 4, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/unreadable": false, + "$feature_flag": "unreadable", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": false, + "locally_evaluated": false + } + ], + [ + "$feature_flag_called", + { + "$feature/missing": null, + "$feature_flag": "missing", + "$feature_flag_error": "flag_missing", + "$feature_flag_evaluated_at": 1800000000000, + "$feature_flag_request_id": "60000000-0000-4000-8000-000000000000", + "$feature_flag_response": null, + "locally_evaluated": false + } + ], + [ + "snapshot-event", + { + "$active_feature_flags": [ + "v1-variant" + ], + "$feature/unreadable": false, + "$feature/v1-variant": "control" + } + ], + [ + "fetched-event", + { + "$active_feature_flags": [ + "v1-variant" + ], + "$feature/unreadable": false, + "$feature/v1-variant": "control" + } + ] + ], + "feature_enabled:missing": null, + "feature_enabled:unreadable": false, + "feature_enabled:v1-variant": true, + "get_all_flags": { + "unreadable": false, + "v1-variant": "control" + }, + "get_all_flags_and_payloads": { + "featureFlagPayloads": { + "v1-variant": "{\"columns\": 2}" + }, + "featureFlags": { + "unreadable": false, + "v1-variant": "control" + } + }, + "get_feature_flag:missing": null, + "get_feature_flag:unreadable": false, + "get_feature_flag:v1-variant": "control", + "get_feature_flag_payload:missing": null, + "get_feature_flag_payload:unreadable": null, + "get_feature_flag_payload:v1-variant": { + "columns": 2 + }, + "get_feature_flag_result:missing": null, + "get_feature_flag_result:unreadable": [ + false, + null, + null, + null + ], + "get_feature_flag_result:v1-variant": [ + true, + "control", + { + "columns": 2 + }, + "Matched condition set 1" + ], + "get_feature_flags_and_payloads": { + "featureFlagPayloads": { + "v1-variant": "{\"columns\": 2}" + }, + "featureFlags": { + "unreadable": false, + "v1-variant": "control" + } + }, + "get_feature_payloads": { + "v1-variant": "{\"columns\": 2}" + }, + "get_feature_variants": { + "unreadable": false, + "v1-variant": "control" + }, + "get_flags_decision": { + "errorsWhileComputingFlags": false, + "evaluatedAt": 1800000000000, + "flags": { + "unreadable": { + "__class__": "FeatureFlag", + "enabled": false, + "key": "unreadable", + "metadata": { + "__class__": "LegacyFlagMetadata", + "payload": null + }, + "reason": null, + "variant": null + }, + "v1-variant": { + "__class__": "FeatureFlag", + "enabled": true, + "key": "v1-variant", + "metadata": { + "__class__": "FlagMetadata", + "description": "A variant flag", + "has_experiment": true, + "id": 701, + "payload": "{\"columns\": 2}", + "version": 4 + }, + "reason": { + "__class__": "FlagReason", + "code": "condition_match", + "condition_index": 0, + "description": "Matched condition set 1" + }, + "variant": "control" + } + }, + "requestId": "60000000-0000-4000-8000-000000000000" + }, + "snapshot.get_flag:missing": null, + "snapshot.get_flag:unreadable": false, + "snapshot.get_flag:v1-variant": "control", + "snapshot.get_flag_payload:missing": null, + "snapshot.get_flag_payload:unreadable": null, + "snapshot.get_flag_payload:v1-variant": { + "columns": 2 + }, + "snapshot.is_enabled:missing": false, + "snapshot.is_enabled:unreadable": false, + "snapshot.is_enabled:v1-variant": true, + "snapshot.keys": [ + "unreadable", + "v1-variant" + ] + } + } +} diff --git a/posthog/test/released_flags_scenarios.py b/posthog/test/released_flags_scenarios.py new file mode 100644 index 000000000..b3cacdac1 --- /dev/null +++ b/posthog/test/released_flags_scenarios.py @@ -0,0 +1,319 @@ +"""Flag scenarios whose results are pinned to a released SDK. + +``fixtures/released_flags_7_82_1.json`` holds the results of these scenarios as +posthog 7.82.1 produced them from the ``v2`` response shape, the shape that release +requests. ``test_flags_released_behaviour.py`` runs the same scenarios on this SDK +from the ``v2`` and the ``v4`` shape and compares them with that file. + +This module uses only the public client API, so it also runs on 7.82.1. To +regenerate the file, install ``posthog==7.82.1`` in a separate environment, load this +module by its path from a directory outside the checkout, so that the installed +package is imported, and write ``{"description": DESCRIPTION, "results": run_all("v2")}`` +to the file as JSON with sorted keys and an indent of 2. +""" + +import copy +import dataclasses +import json +import warnings +from typing import Any, Callable, Dict, List, Optional +from unittest import mock + +from requests.exceptions import ConnectionError as RequestsConnectionError +from requests.exceptions import Timeout as RequestsTimeout + +from posthog import Client +from posthog.request import APIError, QuotaLimitError + +DESCRIPTION = ( + "Results of the scenarios in posthog/test/released_flags_scenarios.py, recorded" + " with posthog 7.82.1 from the v2 response shape. See that module to regenerate." +) +API_KEY = "phc_released_flags_scenarios" +DISTINCT_ID = "user-1" +REQUEST_ID = "60000000-0000-4000-8000-000000000000" +EVALUATED_AT = 1800000000000 +MATCHED = { + "code": "condition_match", + "condition_index": 0, + "description": "Matched condition set 1", +} + + +def _flag(key: str, value: Any, **fields: Any) -> Dict[str, Any]: + """One flag outcome, rendered into a response by ``build_response``.""" + flag: Dict[str, Any] = { + "key": key, + "value": value, + "config_version": 1, + "payload": None, + "metadata": {"id": 700, "version": 3, "description": None}, + "has_experiment": False, + "reason": MATCHED, + "failed": False, + "set": {}, + } + flag.update(fields) + return flag + + +# Flags read by the getter scenario. A config version 2 flag carries a typed value; +# the v2 response renders it the way the server does for older SDKs. +GETTER_FLAGS = [ + _flag("bool-on", True), + _flag("bool-off", False, reason={"code": "no_condition_match"}), + _flag( + "v1-variant", + "control", + payload='{"columns": 2}', + has_experiment=True, + metadata={"id": 701, "version": 4, "description": "A variant flag"}, + ), + _flag("v1-bool-payload", True, payload='{"cta": "Try it", "n": 1}'), + _flag("v1-string-payload", True, payload='"plain"'), + _flag("v2-string", "compact", config_version=2), + _flag("v2-null", None, config_version=2), + _flag("v2-number", 12.5, config_version=2), + _flag("v2-object", {"layout": "compact", "n": [1, True]}, config_version=2), + _flag("sparse", "compact", metadata={}, has_experiment=None, reason=None), + _flag("server-failed", "compact", failed=True), +] + + +def build_response( + shape: str, flags: List[Dict[str, Any]], **top_level: Any +) -> Dict[str, Any]: + """The ``/flags`` response of the given shape (``"v2"`` or ``"v4"``) for flags.""" + records: Dict[str, Any] = {} + for flag in flags: + value = None if flag["failed"] else flag["value"] + metadata = None if flag["metadata"] is None else dict(flag["metadata"]) + if metadata is not None and flag["has_experiment"] is not None: + metadata["has_experiment"] = flag["has_experiment"] + record: Dict[str, Any] = {"key": flag["key"]} + if shape == "v2": + typed = flag["config_version"] != 1 and not isinstance( + value, (bool, str, type(None)) + ) + record["enabled"] = value is not None and value is not False + record["variant"] = value if isinstance(value, str) else None + if metadata is not None: + metadata["payload"] = ( + json.dumps(value, separators=(",", ":")) + if typed + else flag["payload"] + ) + else: + record["value"] = value + if metadata is not None: + metadata["config_version"] = flag["config_version"] + if flag["config_version"] == 1: + metadata["payload"] = flag["payload"] + if isinstance(value, str): + metadata["variant_key"] = value + if flag["failed"]: + record["failed"] = True + if flag["reason"] is not None: + record["reason"] = flag["reason"] + if metadata is not None: + record["metadata"] = metadata + record.update(copy.deepcopy(flag["set"])) + records[flag["key"]] = record + response: Dict[str, Any] = { + "flags": records, + "errorsWhileComputingFlags": False, + "requestId": REQUEST_ID, + "evaluatedAt": EVALUATED_AT, + } + response.update(top_level) + return response + + +def plain(value: Any) -> Any: + """A JSON-compatible copy of a result, with each dataclass's public fields.""" + if dataclasses.is_dataclass(value) and not isinstance(value, type): + fields = { + field.name: plain(getattr(value, field.name)) + for field in dataclasses.fields(value) + } + return {"__class__": type(value).__name__, **fields} + if isinstance(value, dict): + return {str(key): plain(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [plain(item) for item in value] + return value + + +def _flag_event_properties(message: Dict[str, Any]) -> Dict[str, Any]: + return { + key: item + for key, item in message["properties"].items() + if key.startswith("$feature") + or key in ("$active_feature_flags", "locally_evaluated") + } + + +def _with_client( + responses: List[Any], body: Callable[[Client, Dict[str, Any]], None], **options: Any +) -> Dict[str, Any]: + """Run ``body`` on a client whose ``/flags`` requests return ``responses`` in turn.""" + client = Client(API_KEY, send=False, **options) + outputs: Dict[str, Any] = {} + events: List[Any] = [] + pending = list(responses) + + def flags(*args: Any, **kwargs: Any) -> Any: + response = pending.pop(0) if len(pending) > 1 else pending[0] + if isinstance(response, Exception): + raise response + return copy.deepcopy(response) + + def enqueue(message: Dict[str, Any], *args: Any, **kwargs: Any) -> Optional[str]: + events.append([message["event"], _flag_event_properties(message)]) + return None + + try: + with ( + mock.patch("posthog.client.flags", side_effect=flags), + mock.patch.object(client, "_enqueue", side_effect=enqueue), + warnings.catch_warnings(), + ): + warnings.simplefilter("ignore") + body(client, outputs) + finally: + client.shutdown() + outputs["events"] = events + return plain(outputs) + + +def _read_every_getter( + client: Client, outputs: Dict[str, Any], keys: List[str] +) -> None: + outputs["get_flags_decision"] = client.get_flags_decision(DISTINCT_ID) + outputs["get_feature_variants"] = client.get_feature_variants(DISTINCT_ID) + outputs["get_feature_payloads"] = client.get_feature_payloads(DISTINCT_ID) + outputs["get_feature_flags_and_payloads"] = client.get_feature_flags_and_payloads( + DISTINCT_ID + ) + outputs["get_all_flags"] = client.get_all_flags(DISTINCT_ID) + outputs["get_all_flags_and_payloads"] = client.get_all_flags_and_payloads( + DISTINCT_ID + ) + for key in keys + ["missing"]: + result = client.get_feature_flag_result(key, DISTINCT_ID) + outputs[f"get_feature_flag_result:{key}"] = ( + None + if result is None + else [result.enabled, result.variant, result.payload, result.reason] + ) + outputs[f"get_feature_flag:{key}"] = client.get_feature_flag(key, "user-2") + outputs[f"feature_enabled:{key}"] = client.feature_enabled(key, "user-3") + outputs[f"get_feature_flag_payload:{key}"] = client.get_feature_flag_payload( + key, DISTINCT_ID + ) + snapshot = client.evaluate_flags("snapshot-user") + outputs["snapshot.keys"] = sorted(snapshot.keys) + for key in keys + ["missing"]: + outputs[f"snapshot.is_enabled:{key}"] = snapshot.is_enabled(key) + outputs[f"snapshot.get_flag:{key}"] = snapshot.get_flag(key) + outputs[f"snapshot.get_flag_payload:{key}"] = snapshot.get_flag_payload(key) + client.capture("snapshot-event", distinct_id="snapshot-user", flags=snapshot) + client.capture("fetched-event", distinct_id="capture-user", send_feature_flags=True) + + +def getters(shape: str, flags: List[Dict[str, Any]]) -> Dict[str, Any]: + """Every legacy getter, snapshot read and flag event for one response.""" + keys = [flag["key"] for flag in flags] + return _with_client( + [build_response(shape, flags)], + lambda client, outputs: _read_every_getter(client, outputs, keys), + ) + + +def _good(shape: str, **fields: Any) -> Dict[str, Any]: + return build_response( + shape, [_flag("cached", "blue", payload='{"theme": "dark"}', **fields)] + ) + + +# Single-flag histories with the memory fallback cache: the responses, in order, to +# the same flag read for the same user. +def cache_histories(shape: str) -> Dict[str, List[Any]]: + return { + "good, unreadable record, timeout": [ + _good(shape), + _good(shape, set={"reason": "oops"}), + RequestsTimeout("timed out"), + ], + "good, each request failure": [ + _good(shape), + RequestsTimeout("timed out"), + RequestsConnectionError("refused"), + APIError(500, "server error"), + QuotaLimitError(429, "quota limited"), + ValueError("unexpected"), + ], + "good, disabled, timeout": [ + _good(shape), + build_response(shape, [_flag("cached", False)]), + RequestsTimeout("timed out"), + ], + "good, server failed, timeout": [ + _good(shape), + _good(shape, failed=True), + RequestsTimeout("timed out"), + ], + "good, missing with errors, timeout": [ + _good(shape), + build_response(shape, [], errorsWhileComputingFlags=True), + RequestsTimeout("timed out"), + ], + "good, other variant, timeout": [ + _good(shape), + build_response(shape, [_flag("cached", "green")]), + RequestsTimeout("timed out"), + ], + } + + +def cache_history(responses: List[Any]) -> Dict[str, Any]: + def body(client: Client, outputs: Dict[str, Any]) -> None: + results = [] + for _ in responses: + # Forget sent events so that every read sends one, with its error. + client.distinct_ids_feature_flags_reported.clear() + result = client.get_feature_flag_result("cached", DISTINCT_ID) + results.append( + None + if result is None + else [result.enabled, result.variant, result.payload, result.reason] + ) + outputs["results"] = results + + return _with_client( + responses + [RequestsTimeout("unused")], + body, + flag_fallback_cache_url="memory://local/?ttl=300&size=100", + ) + + +UNREADABLE = _flag("unreadable", "compact", set={"reason": "oops"}) +# What a record that cannot be read reads as: disabled, without reason or metadata. +UNREADABLE_AS_DISABLED = _flag("unreadable", False, metadata=None, reason=None) + + +def run_all(shape: str) -> Dict[str, Any]: + """Every scenario's results for the given response shape.""" + return { + "getters": getters(shape, GETTER_FLAGS), + "getters with an unreadable record": getters( + shape, [GETTER_FLAGS[2], UNREADABLE] + ), + "getters with the unreadable record read as disabled": getters( + shape, [GETTER_FLAGS[2], UNREADABLE_AS_DISABLED] + ), + "cache histories": { + name: cache_history(responses) + for name, responses in cache_histories(shape).items() + }, + } diff --git a/posthog/test/test_flags_released_behaviour.py b/posthog/test/test_flags_released_behaviour.py new file mode 100644 index 000000000..54919eb45 --- /dev/null +++ b/posthog/test/test_flags_released_behaviour.py @@ -0,0 +1,68 @@ +"""Legacy flag results pinned to the results posthog 7.82.1 recorded. + +``fixtures/released_flags_7_82_1.json`` was recorded with posthog 7.82.1 from the +``v2`` response shape; ``released_flags_scenarios.py`` describes the scenarios and how +to regenerate the file. Each scenario runs here from the ``v2`` shape and from the +``v4`` shape of the same flags, so a change shared by both readers is caught too. +Where the expected result differs from 7.82.1, the test says which decision makes +it differ. +""" + +import copy +import json +from pathlib import Path +from typing import Any, Dict + +import pytest + +from posthog.test import released_flags_scenarios as scenarios + +RELEASED = json.loads( + (Path(__file__).with_name("fixtures") / "released_flags_7_82_1.json").read_text( + encoding="utf-8" + ) +)["results"] +SHAPES = ["v2", "v4"] + + +@pytest.fixture(scope="module", params=SHAPES) +def results(request: Any) -> Dict[str, Any]: + return scenarios.run_all(request.param) + + +def test_every_legacy_getter_and_event_matches_7_82_1(results): + expected = copy.deepcopy(RELEASED["getters"]) + # A record without a flag ID or version reads them as absent (None), where 7.82.1 + # read 0. Events already left out both, so only the parsed record differs. + sparse = expected["get_flags_decision"]["flags"]["sparse"]["metadata"] + assert (sparse["id"], sparse["version"]) == (0, 0) + sparse["id"] = sparse["version"] = None + + assert results["getters"] == expected + + +def test_an_unreadable_record_fails_only_itself(results): + # 7.82.1 failed the whole response when one record had a field of the wrong + # type. That record now fails alone and reads as a disabled flag without reason + # or metadata, and its siblings read as before. + assert RELEASED["getters with an unreadable record"]["get_all_flags"] == {} + + expected = RELEASED["getters with the unreadable record read as disabled"] + assert results["getters with an unreadable record"] == expected + assert results["getters with the unreadable record read as disabled"] == expected + + +@pytest.mark.parametrize("name", sorted(RELEASED["cache histories"])) +def test_fallback_cache_histories_match_7_82_1(results, name): + expected = copy.deepcopy(RELEASED["cache histories"][name]) + if name == "good, unreadable record, timeout": + # 7.82.1 failed the whole response on the unreadable record, so its event + # had an unknown_error and no request details. The request now succeeds and + # only the record fails, so the event carries the request details and no + # error, while the value still falls back to the cached one. + unreadable_read = expected["events"][1][1] + assert unreadable_read.pop("$feature_flag_error") == "unknown_error" + unreadable_read["$feature_flag_request_id"] = scenarios.REQUEST_ID + unreadable_read["$feature_flag_evaluated_at"] = scenarios.EVALUATED_AT + + assert results["cache histories"][name] == expected From f8fe7d07251e30affed64538ac8e7023bfaf9574 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Mon, 2 Nov 2026 20:35:14 +0100 Subject: [PATCH 34/35] fix(flags): report an unreadable record's read as an unknown error A single-flag read of a record that cannot be read used to fail the whole request, so its $feature_flag_called event carried $feature_flag_error: unknown_error and no request id or evaluated-at. The read now succeeds for the other records, but this one still reports the same error and no request details, so a value served from the fallback cache stays distinguishable from a fresh one. --- posthog/client.py | 6 +++- posthog/test/test_flags_released_behaviour.py | 33 +++++++++++-------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index 4cc2ac914..ce5eb3352 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -4189,7 +4189,11 @@ def _get_feature_flag_result( ): # The record could not be read, so it reads as disabled. It must # not replace the last good cached value, which it falls back to - # like a failed request. + # like a failed request, and it reports what a failed request + # reports: an unknown error and no request details. + feature_flag_error = FeatureFlagError.UNKNOWN_ERROR + request_id = None + evaluated_at = None flag_result = ( self._get_stale_flag_fallback(distinct_id, key) or flag_result ) diff --git a/posthog/test/test_flags_released_behaviour.py b/posthog/test/test_flags_released_behaviour.py index 54919eb45..7ad8022f8 100644 --- a/posthog/test/test_flags_released_behaviour.py +++ b/posthog/test/test_flags_released_behaviour.py @@ -47,22 +47,29 @@ def test_an_unreadable_record_fails_only_itself(results): # or metadata, and its siblings read as before. assert RELEASED["getters with an unreadable record"]["get_all_flags"] == {} - expected = RELEASED["getters with the unreadable record read as disabled"] + as_disabled = RELEASED["getters with the unreadable record read as disabled"] + assert results["getters with the unreadable record read as disabled"] == as_disabled + + # A single-flag read of the unreadable record still reports what 7.82.1 + # reported when the whole response failed: an unknown_error and no request + # details. Only its value differs, disabled instead of None. + expected = copy.deepcopy(as_disabled) + released_events = RELEASED["getters with an unreadable record"]["events"] + for event, released in zip(expected["events"], released_events): + if ( + released[1].get("$feature_flag") == "unreadable" + and released[1].get("$feature_flag_error") == "unknown_error" + ): + event[1].pop("$feature_flag_request_id") + event[1].pop("$feature_flag_evaluated_at") + event[1]["$feature_flag_error"] = "unknown_error" assert results["getters with an unreadable record"] == expected - assert results["getters with the unreadable record read as disabled"] == expected @pytest.mark.parametrize("name", sorted(RELEASED["cache histories"])) def test_fallback_cache_histories_match_7_82_1(results, name): - expected = copy.deepcopy(RELEASED["cache histories"][name]) - if name == "good, unreadable record, timeout": - # 7.82.1 failed the whole response on the unreadable record, so its event - # had an unknown_error and no request details. The request now succeeds and - # only the record fails, so the event carries the request details and no - # error, while the value still falls back to the cached one. - unreadable_read = expected["events"][1][1] - assert unreadable_read.pop("$feature_flag_error") == "unknown_error" - unreadable_read["$feature_flag_request_id"] = scenarios.REQUEST_ID - unreadable_read["$feature_flag_evaluated_at"] = scenarios.EVALUATED_AT - + # An unreadable record now fails only itself, but a single-flag read of it + # still reports what 7.82.1 reported when the whole response failed: an + # unknown_error, no request details, and the cached value. + expected = RELEASED["cache histories"][name] assert results["cache histories"][name] == expected From ef2d974dbe5c86ec561f79c035b3c7f3dfe2dd5f Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Tue, 3 Nov 2026 14:06:16 +0100 Subject: [PATCH 35/35] test(flags): compare 7.82.1 results without the fields this change makes public --- posthog/test/test_flags_released_behaviour.py | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/posthog/test/test_flags_released_behaviour.py b/posthog/test/test_flags_released_behaviour.py index 7ad8022f8..7a1c36eaa 100644 --- a/posthog/test/test_flags_released_behaviour.py +++ b/posthog/test/test_flags_released_behaviour.py @@ -25,11 +25,57 @@ SHAPES = ["v2", "v4"] +# This SDK makes these FeatureFlag and FlagMetadata fields public, so the flags in +# get_flags_decision() carry them. 7.82.1 had none of them; every other field must +# still match its recording. +NEW_PUBLIC_FLAG_FIELDS = ("value", "failed", "error_code", "error_message") +NEW_PUBLIC_METADATA_FIELDS = ( + "config_version", + "rule_type", + "rule_id", + "variant_key", + "forced_variant", +) + + @pytest.fixture(scope="module", params=SHAPES) -def results(request: Any) -> Dict[str, Any]: +def results_with_new_fields(request: Any) -> Dict[str, Any]: return scenarios.run_all(request.param) +@pytest.fixture(scope="module") +def results(results_with_new_fields: Dict[str, Any]) -> Dict[str, Any]: + stripped = copy.deepcopy(results_with_new_fields) + for scenario in stripped.values(): + decision = scenario.get("get_flags_decision") + for flag in (decision or {}).get("flags", {}).values(): + for name in NEW_PUBLIC_FLAG_FIELDS: + flag.pop(name) + if flag["metadata"]["__class__"] == "FlagMetadata": + for name in NEW_PUBLIC_METADATA_FIELDS: + flag["metadata"].pop(name) + return stripped + + +def test_get_flags_decision_carries_the_new_public_fields(results_with_new_fields): + flags = results_with_new_fields["getters"]["get_flags_decision"]["flags"] + assert {name: flags["bool-on"][name] for name in NEW_PUBLIC_FLAG_FIELDS} == { + "value": True, + "failed": False, + "error_code": None, + "error_message": None, + } + assert flags["bool-on"]["metadata"]["config_version"] == 1 + assert flags["server-failed"]["failed"] is True + assert flags["server-failed"]["error_code"] == "GENERAL" + + unreadable = results_with_new_fields["getters with an unreadable record"][ + "get_flags_decision" + ]["flags"]["unreadable"] + assert (unreadable["value"], unreadable["failed"]) == (None, True) + assert unreadable["error_code"] == "PARSE_ERROR" + + def test_every_legacy_getter_and_event_matches_7_82_1(results): expected = copy.deepcopy(RELEASED["getters"]) # A record without a flag ID or version reads them as absent (None), where 7.82.1