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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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 8d1db309fbe3eb9cd7892f40e33989dd8173dafc Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Thu, 29 Oct 2026 20:51:12 +0100 Subject: [PATCH 10/34] 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 11/34] 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 12/34] 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 13/34] 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 5000d44a99a25fb4210fd32621985c4a7484490e Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Thu, 29 Oct 2026 21:49:58 +0100 Subject: [PATCH 14/34] 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 ad1b1fba0c6be48a6aa35844ff6ed628a55b1785 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Fri, 30 Oct 2026 10:00:32 +0100 Subject: [PATCH 15/34] 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 efe9ba890181e905df2a63fcdd444106c84c6547 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Fri, 30 Oct 2026 12:48:25 +0100 Subject: [PATCH 16/34] test(flags): vendor the flag event property schemas Copy the $feature_flag_called and $experiment_exposure property schemas unchanged from the same test harness release as the other contract fixtures, and record their digests in SOURCE.json. --- .../feature_flag_rules_v2/SOURCE.json | 8 + ...experiment_exposure_properties.schema.json | 66 +++ .../feature_flag_called_context.schema.json | 481 ++++++++++++++++++ 3 files changed, 555 insertions(+) create mode 100644 posthog/test/fixtures/feature_flag_rules_v2/experiment_exposure_properties.schema.json create mode 100644 posthog/test/fixtures/feature_flag_rules_v2/feature_flag_called_context.schema.json diff --git a/posthog/test/fixtures/feature_flag_rules_v2/SOURCE.json b/posthog/test/fixtures/feature_flag_rules_v2/SOURCE.json index df7f1c7e0..a9e158b1c 100644 --- a/posthog/test/fixtures/feature_flag_rules_v2/SOURCE.json +++ b/posthog/test/fixtures/feature_flag_rules_v2/SOURCE.json @@ -21,6 +21,14 @@ "v2_value_evaluation.json": { "source_path": "corpus/v2_value_evaluation.json", "sha256": "aaddd05e47fc5a4ae07ea42ada5a659798f4b5fc8d0c0827f6a68e4daf68c128" + }, + "feature_flag_called_context.schema.json": { + "source_path": "schemas/feature_flag_called_context.schema.json", + "sha256": "56ca931f58ce41f22f5bdf63da99f1ad543e78d32de08f1762c3c10b07fcabc5" + }, + "experiment_exposure_properties.schema.json": { + "source_path": "schemas/experiment_exposure_properties.schema.json", + "sha256": "0cd39571ef19749ae380a1e88384f97916e5d0ba126f9e461026785a6d3f0aad" } } } diff --git a/posthog/test/fixtures/feature_flag_rules_v2/experiment_exposure_properties.schema.json b/posthog/test/fixtures/feature_flag_rules_v2/experiment_exposure_properties.schema.json new file mode 100644 index 000000000..5ec195eab --- /dev/null +++ b/posthog/test/fixtures/feature_flag_rules_v2/experiment_exposure_properties.schema.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://posthog.com/contracts/feature_flag_rules_v2/experiment_exposure_properties.schema.json", + "title": "SDK-origin $experiment_exposure required properties", + "type": "object", + "additionalProperties": false, + "required": [ + "$feature_flag", + "$feature_flag_response", + "$feature_flag_config_version", + "$feature_flag_variant", + "$feature_flag_rule_id", + "$feature_flag_rule_type", + "$feature_flag_reason_code", + "$experiment_exposure_source", + "locally_evaluated" + ], + "properties": { + "$feature_flag": { + "type": "string", + "minLength": 1 + }, + "$feature_flag_response": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "minLength": 1 + } + ] + }, + "$feature_flag_config_version": { + "const": 2 + }, + "$feature_flag_variant": { + "type": "string" + }, + "$feature_flag_rule_id": { + "type": "string", + "format": "uuid" + }, + "$feature_flag_rule_type": { + "const": "experiment" + }, + "$feature_flag_reason_code": { + "const": "experiment_split" + }, + "$experiment_exposure_source": { + "const": "sdk" + }, + "locally_evaluated": { + "type": "boolean" + }, + "$feature_flag_forced_variant": { + "const": true + } + }, + "description": "Closed projection of required SDK-origin exposure properties; forced-variant context is forbidden. Capture identity and existing optional transport properties are outside this projection. Scan the entire event recursively for assignment seeds.", + "not": { + "required": [ + "$feature_flag_forced_variant" + ] + } +} diff --git a/posthog/test/fixtures/feature_flag_rules_v2/feature_flag_called_context.schema.json b/posthog/test/fixtures/feature_flag_rules_v2/feature_flag_called_context.schema.json new file mode 100644 index 000000000..1b2924e88 --- /dev/null +++ b/posthog/test/fixtures/feature_flag_rules_v2/feature_flag_called_context.schema.json @@ -0,0 +1,481 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://posthog.com/contracts/feature_flag_rules_v2/feature_flag_called_context.schema.json", + "title": "$feature_flag_called context properties", + "type": "object", + "additionalProperties": false, + "required": [ + "$feature_flag", + "$feature_flag_response" + ], + "properties": { + "$feature_flag": { + "type": "string", + "minLength": 1 + }, + "$feature_flag_response": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "minLength": 1 + } + ] + }, + "$feature_flag_config_version": { + "enum": [ + 1, + 2 + ] + }, + "$feature_flag_variant": { + "type": "string" + }, + "$feature_flag_rule_id": { + "type": "string", + "format": "uuid" + }, + "$feature_flag_rule_type": { + "enum": [ + "targeted_release", + "percentage_rollout", + "experiment" + ] + }, + "$feature_flag_experiment_id": { + "type": "integer" + }, + "$feature_flag_reason_code": { + "type": "string", + "minLength": 1 + }, + "$feature_flag_forced_variant": { + "const": true + } + }, + "description": "Closed projection of the flag-access context properties. Existing capture fields are outside this projection. Complete v2 producer context follows the terminal outcome; tolerant-reader diagnostics may omit an incomplete attribution tuple.", + "allOf": [ + { + "if": { + "required": [ + "$feature_flag_config_version" + ], + "properties": { + "$feature_flag_config_version": { + "const": 2 + } + } + }, + "then": { + "required": [ + "$feature_flag_reason_code" + ], + "not": { + "required": [ + "$feature_flag_forced_variant" + ] + }, + "oneOf": [ + { + "properties": { + "$feature_flag_reason_code": { + "const": "targeting_match" + }, + "$feature_flag_rule_type": { + "const": "targeted_release" + } + }, + "required": [ + "$feature_flag_rule_type", + "$feature_flag_rule_id" + ], + "not": { + "anyOf": [ + { + "required": [ + "$feature_flag_experiment_id" + ] + }, + { + "required": [ + "$feature_flag_variant" + ] + } + ] + } + }, + { + "properties": { + "$feature_flag_reason_code": { + "const": "targeting_match" + }, + "$feature_flag_rule_type": { + "const": "percentage_rollout" + } + }, + "required": [ + "$feature_flag_rule_type", + "$feature_flag_rule_id" + ], + "not": { + "anyOf": [ + { + "required": [ + "$feature_flag_experiment_id" + ] + }, + { + "required": [ + "$feature_flag_variant" + ] + } + ] + } + }, + { + "properties": { + "$feature_flag_reason_code": { + "const": "experiment_split" + }, + "$feature_flag_rule_type": { + "const": "experiment" + } + }, + "required": [ + "$feature_flag_rule_type", + "$feature_flag_rule_id", + "$feature_flag_variant" + ], + "not": { + "anyOf": [ + { + "required": [ + "$feature_flag_experiment_id" + ] + } + ] + } + }, + { + "properties": { + "$feature_flag_reason_code": { + "const": "rollout_miss" + }, + "$feature_flag_rule_type": { + "const": "percentage_rollout" + } + }, + "required": [ + "$feature_flag_rule_type", + "$feature_flag_rule_id" + ], + "not": { + "anyOf": [ + { + "required": [ + "$feature_flag_experiment_id" + ] + }, + { + "required": [ + "$feature_flag_variant" + ] + } + ] + } + }, + { + "properties": { + "$feature_flag_reason_code": { + "const": "rollout_miss" + }, + "$feature_flag_rule_type": { + "const": "experiment" + } + }, + "required": [ + "$feature_flag_rule_type", + "$feature_flag_rule_id" + ], + "not": { + "anyOf": [ + { + "required": [ + "$feature_flag_experiment_id" + ] + }, + { + "required": [ + "$feature_flag_variant" + ] + } + ] + } + }, + { + "properties": { + "$feature_flag_reason_code": { + "const": "experiment_paused" + }, + "$feature_flag_rule_type": { + "const": "experiment" + } + }, + "required": [ + "$feature_flag_rule_type", + "$feature_flag_rule_id" + ], + "not": { + "anyOf": [ + { + "required": [ + "$feature_flag_experiment_id" + ] + }, + { + "required": [ + "$feature_flag_variant" + ] + } + ] + } + }, + { + "properties": { + "$feature_flag_reason_code": { + "const": "holdout" + }, + "$feature_flag_rule_type": { + "const": "experiment" + } + }, + "required": [ + "$feature_flag_rule_type", + "$feature_flag_rule_id" + ], + "not": { + "anyOf": [ + { + "required": [ + "$feature_flag_experiment_id" + ] + }, + { + "required": [ + "$feature_flag_variant" + ] + } + ] + } + }, + { + "properties": { + "$feature_flag_reason_code": { + "const": "no_rule_match" + } + }, + "not": { + "anyOf": [ + { + "required": [ + "$feature_flag_rule_type" + ] + }, + { + "required": [ + "$feature_flag_rule_id" + ] + }, + { + "required": [ + "$feature_flag_experiment_id" + ] + }, + { + "required": [ + "$feature_flag_variant" + ] + } + ] + } + }, + { + "properties": { + "$feature_flag_reason_code": { + "const": "missing_group_key" + } + }, + "not": { + "anyOf": [ + { + "required": [ + "$feature_flag_rule_type" + ] + }, + { + "required": [ + "$feature_flag_rule_id" + ] + }, + { + "required": [ + "$feature_flag_experiment_id" + ] + }, + { + "required": [ + "$feature_flag_variant" + ] + } + ] + } + }, + { + "properties": { + "$feature_flag_reason_code": { + "const": "dependency_error" + } + }, + "not": { + "anyOf": [ + { + "required": [ + "$feature_flag_rule_type" + ] + }, + { + "required": [ + "$feature_flag_rule_id" + ] + }, + { + "required": [ + "$feature_flag_experiment_id" + ] + }, + { + "required": [ + "$feature_flag_variant" + ] + } + ] + } + }, + { + "properties": { + "$feature_flag_reason_code": { + "const": "error" + } + }, + "not": { + "anyOf": [ + { + "required": [ + "$feature_flag_rule_type" + ] + }, + { + "required": [ + "$feature_flag_rule_id" + ] + }, + { + "required": [ + "$feature_flag_experiment_id" + ] + }, + { + "required": [ + "$feature_flag_variant" + ] + } + ] + } + }, + { + "description": "A local-only outcome with no matrix row: the server never returns flag_disabled, and the call carries no rule or experiment attribution.", + "properties": { + "$feature_flag_reason_code": { + "const": "flag_disabled" + } + }, + "not": { + "anyOf": [ + { + "required": [ + "$feature_flag_rule_type" + ] + }, + { + "required": [ + "$feature_flag_rule_id" + ] + }, + { + "required": [ + "$feature_flag_experiment_id" + ] + }, + { + "required": [ + "$feature_flag_variant" + ] + } + ] + } + }, + { + "description": "A tolerant reader may emit a diagnostic call for a malformed split after omitting all rule/experiment attribution. The config marker and reason remain available for diagnostics.", + "properties": { + "$feature_flag_reason_code": { + "const": "experiment_split" + } + }, + "not": { + "anyOf": [ + { + "required": [ + "$feature_flag_rule_type" + ] + }, + { + "required": [ + "$feature_flag_rule_id" + ] + }, + { + "required": [ + "$feature_flag_experiment_id" + ] + }, + { + "required": [ + "$feature_flag_variant" + ] + } + ] + } + } + ] + }, + "else": { + "not": { + "anyOf": [ + { + "required": [ + "$feature_flag_rule_id" + ] + }, + { + "required": [ + "$feature_flag_rule_type" + ] + } + ] + } + } + } + ] +} From fdb27095fd201ac1a3fe3de4df8539962ed76e6e Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Fri, 30 Oct 2026 12:48:31 +0100 Subject: [PATCH 17/34] feat(flags): send $experiment_exposure for v2 experiment splits A remotely evaluated config version 2 flag whose record assigns an experiment variant now sends $experiment_exposure next to $feature_flag_called. $feature_flag_called for every config version 2 flag also reports its config version, reason code, rule ID, rule type and, for a complete split, variant key, and its dedupe key includes them so equal-valued arms and successive experiments are each reported. Both events are built in one step that every flag read goes through on both clients, from the parsed record that supplied the value. The exposure has its own dedupe cache, keyed by distinct ID, groups, flag, rule and variant, so a reported call never suppresses it. A split missing its rule type, rule ID or variant key sends no exposure and reports no rule tuple. Config version 1 flags, local evaluation and $feature_flag_response are unchanged. --- .sampo/changesets/experiment-exposure.md | 5 + posthog/async_client.py | 44 +- posthog/client.py | 324 ++++++-- posthog/feature_flag_evaluations.py | 8 + posthog/test/test_experiment_exposure.py | 989 +++++++++++++++++++++++ posthog/test/test_flags_v4_response.py | 16 +- 6 files changed, 1305 insertions(+), 81 deletions(-) create mode 100644 .sampo/changesets/experiment-exposure.md create mode 100644 posthog/test/test_experiment_exposure.py diff --git a/.sampo/changesets/experiment-exposure.md b/.sampo/changesets/experiment-exposure.md new file mode 100644 index 000000000..9bc510371 --- /dev/null +++ b/.sampo/changesets/experiment-exposure.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Send `$experiment_exposure` when a remotely evaluated config version 2 flag assigns an experiment variant. The SDK sends it together with `$feature_flag_called`, from `get_feature_flag()`, `feature_enabled()`, `get_feature_flag_result()`, and the `is_enabled()` and `get_flag()` reads of an `evaluate_flags()` snapshot. It carries the flag key, the same `$feature_flag_response` as its `$feature_flag_called`, the rule ID, rule type and variant key, the reason code, `$experiment_exposure_source: "sdk"` and `locally_evaluated: false`, and the flag ID, version, request ID and evaluation time when the response has them. It is sent once per distinct ID, groups, flag, rule and variant, whatever value or request it came from. `$feature_flag_called` for a config version 2 flag also gets `$feature_flag_config_version`, `$feature_flag_reason_code`, and `$feature_flag_rule_id`, `$feature_flag_rule_type` and `$feature_flag_variant` when the flag has them. Minimal `$feature_flag_called` events keep these properties, and their dedupe includes them, so equal-valued arms and successive experiments of one flag are each reported. A split that lacks its rule type, rule ID or variant key sends no exposure and reports no rule or variant. `send_feature_flag_events=False` suppresses both events. Config version 1 flags, locally evaluated flags and `$feature_flag_response` are unchanged. diff --git a/posthog/async_client.py b/posthog/async_client.py index 323b16b89..3acba87c0 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -38,8 +38,8 @@ from .capture_mode import CaptureMode, _resolve_capture_mode from .client import ( MAX_DICT_SIZE as _MAX_DICT_SIZE, - _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES, Client as _SyncClient, + _capture_flag_events, _metadata_has_experiment, _parse_flag_payload, add_context_tags as _add_context_tags, @@ -78,6 +78,7 @@ from .release_id import _resolve_release_id from .request import QuotaLimitError, determine_server_host, normalize_host from .types import ( + FeatureFlag, FlagMetadata, FlagValue, _has_untyped_flag_records, @@ -186,6 +187,9 @@ def __init__( 0, feature_flags_request_max_retries ) self.distinct_ids_feature_flags_reported = SizeLimitedDict(_MAX_DICT_SIZE, set) + # $experiment_exposure dedupe, kept apart from $feature_flag_called's so that a + # reported call never suppresses its exposure. Same bound and eviction. + self._experiment_exposures_reported = SizeLimitedDict(_MAX_DICT_SIZE, set) self._feature_flag_evaluations_host_cache: Optional[ _FeatureFlagEvaluationsHost ] = None @@ -1054,38 +1058,29 @@ def _capture_feature_flag_called_if_needed( disable_geoip: Optional[bool] = None, has_experiment: Optional[bool] = None, minimal_flag_called_events: bool = False, + details: Optional[FeatureFlag] = None, + value_from_details: bool = True, ) -> None: - groups_key = ( - tuple(sorted((str(k), str(v)) for k, v in groups.items())) if groups else () - ) - reported_key = (key, response, groups_key) - reported_flags = self.distinct_ids_feature_flags_reported.get(distinct_id) - if reported_flags is None: - reported_flags = set() - self.distinct_ids_feature_flags_reported[distinct_id] = reported_flags - if reported_key in reported_flags: - return - - if has_experiment is not None: - properties["$feature_flag_has_experiment"] = has_experiment - capture_kwargs: dict[str, Any] = {} - if minimal_flag_called_events and has_experiment is False: - capture_kwargs["_property_allowlist"] = ( - _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES - ) capture_groups = { str(group_type): _stringify_id(group_key) for group_type, group_key in (groups or {}).items() } - self.capture( - "$feature_flag_called", + _capture_flag_events( + self.capture, + self.distinct_ids_feature_flags_reported, + self._experiment_exposures_reported, distinct_id=distinct_id, + key=key, + response=response, properties=properties, - groups=capture_groups, + groups=groups, + capture_groups=capture_groups, disable_geoip=disable_geoip, - **capture_kwargs, + has_experiment=has_experiment, + minimal_flag_called_events=minimal_flag_called_events, + details=details, + value_from_details=value_from_details, ) - reported_flags.add(reported_key) def _pending_queue_items(self) -> int: return int(getattr(self._queue, "_unfinished_tasks", self._queue.qsize())) @@ -1240,6 +1235,7 @@ async def shutdown(self) -> None: errors.append(error) self.distinct_ids_feature_flags_reported.clear() + self._experiment_exposures_reported.clear() self._closed = True if errors and self.debug: raise errors[0] diff --git a/posthog/client.py b/posthog/client.py index 4c090029b..7012abcce 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -11,7 +11,18 @@ import weakref from contextvars import ContextVar from datetime import datetime, timedelta, timezone -from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, Union, cast +from typing import ( + Any, + Callable, + Dict, + List, + Mapping, + NamedTuple, + Optional, + Tuple, + Union, + cast, +) from uuid import UUID, uuid4 from typing_extensions import Unpack @@ -330,6 +341,12 @@ def wrapper(self, *args, **kwargs): "$feature_flag_evaluated_at", "$feature_flag_error", "locally_evaluated", + # Config version 2 attribution, which minimal events must keep + "$feature_flag_config_version", + "$feature_flag_reason_code", + "$feature_flag_rule_id", + "$feature_flag_rule_type", + "$feature_flag_variant", # Correctness-required "$groups", "$process_person_profile", @@ -362,6 +379,219 @@ def _metadata_has_experiment(metadata: Any) -> Optional[bool]: return metadata.has_experiment if isinstance(metadata, FlagMetadata) else None +# The reason code and rule type of the one outcome that sends ``$experiment_exposure``: +# a variant assigned by an experiment rule. +_EXPERIMENT_SPLIT_REASON = "experiment_split" +_EXPERIMENT_RULE_TYPE = "experiment" + +# ``$experiment_exposure`` copies these from the ``$feature_flag_called`` of the same +# read, when it has them. +_EXPOSURE_PROPERTIES_FROM_FLAG_CALLED = ( + "$feature_flag_id", + "$feature_flag_version", + "$feature_flag_request_id", + "$feature_flag_evaluated_at", +) + + +def _flag_record_metadata(metadata: FlagMetadata, name: str) -> Any: + """Read a ``/flags?v=4`` metadata field of a flag record, such as ``config_version``. + + These fields stay private, as ``_config_version`` and so on, until the typed flag + accessors make them public under their wire names. Either spelling is read, so + the two changes can land in either order. + """ + return getattr(metadata, name, getattr(metadata, f"_{name}", None)) + + +class _RuleAttribution(NamedTuple): + """What a config version 2 flag record reports on its flag events.""" + + reason_code: Optional[str] + rule_id: Optional[str] + rule_type: Optional[str] + # Set only for a complete experiment split, the outcome that sends an exposure. + variant_key: Optional[str] + + def flag_called_properties(self) -> Dict[str, Any]: + properties: Dict[str, Any] = {"$feature_flag_config_version": 2} + if self.reason_code is not None: + properties["$feature_flag_reason_code"] = self.reason_code + if self.rule_id is not None: + properties["$feature_flag_rule_id"] = self.rule_id + if self.rule_type is not None: + properties["$feature_flag_rule_type"] = self.rule_type + if self.variant_key is not None: + properties["$feature_flag_variant"] = self.variant_key + return properties + + +def _rule_attribution(details: Optional[FeatureFlag]) -> Optional[_RuleAttribution]: + """Read the event attribution of a config version 2 flag record. + + Returns ``None`` for anything else: no record, a config version 1 record, or a + record from a server without typed values. Only the evaluation details are read, + never the value a getter returned. + + The rule ID and rule type are reported when the record has them. The variant key + is reported only for an experiment split, and only when the split is complete: + reason ``experiment_split``, rule type ``experiment``, a rule ID and a variant + key. A split without all of them is malformed. It keeps its reason but reports no + rule ID, rule type or variant, and sends no exposure. Its identity is never + guessed from the value, the key or an earlier result. + """ + if details is None or not isinstance(details.metadata, FlagMetadata): + return None + metadata = details.metadata + if _flag_record_metadata(metadata, "config_version") != 2: + return None + reason_code = (details.reason.code if details.reason else None) or None + rule_id = _flag_record_metadata(metadata, "rule_id") or None + rule_type = _flag_record_metadata(metadata, "rule_type") or None + variant_key = _flag_record_metadata(metadata, "variant_key") or None + if reason_code != _EXPERIMENT_SPLIT_REASON: + # Only an experiment split assigns a variant. + return _RuleAttribution(reason_code, rule_id, rule_type, None) + if rule_type != _EXPERIMENT_RULE_TYPE or rule_id is None or variant_key is None: + return _RuleAttribution(reason_code, None, None, None) + return _RuleAttribution(reason_code, rule_id, rule_type, variant_key) + + +def _capture_flag_events( + capture: Callable[..., Any], + flag_calls_reported: Dict[Any, Any], + exposures_reported: Dict[Any, Any], + *, + distinct_id: ID_TYPES, + key: str, + response: Optional[FlagValue], + properties: Dict[str, Any], + groups: Optional[Mapping[str, Union[str, int]]], + capture_groups: Mapping[str, Any], + disable_geoip: Optional[bool], + has_experiment: Optional[bool], + minimal_flag_called_events: bool, + details: Optional[FeatureFlag], + value_from_details: bool, +) -> None: + """Send the flag events of one flag read: ``$feature_flag_called`` and, for an + experiment split, ``$experiment_exposure``. + + Every read that sends flag events comes here, on both clients: the single-flag + getters and the reads of an ``evaluate_flags()`` snapshot. Both events are built + from ``details``, the parsed ``/flags`` record that supplied the value, and the + call's ``properties``; the flag is never evaluated again. + + ``$feature_flag_called`` is sent if the (distinct_id, flag, response, groups) + tuple hasn't already been reported on this client. Group context is included so + that group-scoped flags fire a separate event for each group a user is evaluated + under. A config version 2 record adds its attribution to the event and to that + tuple, so equal-valued A/A arms, successive experiments of one flag, and + holdouts are each reported. A config version 1 tuple is unchanged. + + ``has_experiment`` is the server-reported signal for whether the flag is linked + to an experiment (``None`` when the server did not report it). When the + server-controlled gate is on and the flag is known non-experiment, the event is + trimmed to a strict allowlist; any missing signal sends the full legacy shape, + and experiment-linked flags keep the full set for exposure analysis. + + ``minimal_flag_called_events`` is the gate as observed by the evaluation that + produced ``response``, not a fresh read of client-wide state: both callers + resolve it themselves (the snapshot pins it at construction; the single-flag + path reads it from the specific local/remote source that produced the value) so + a concurrent poller refresh or another ``/flags`` call can't reshape an event + after the fact. + + ``$experiment_exposure`` is sent for a complete config version 2 experiment + split whose record supplied the reported value (``value_from_details``), once + per distinct ID, groups, flag, rule and variant. It has its own dedupe, so a + ``$feature_flag_called`` already reported never suppresses it. + """ + groups_key = ( + tuple(sorted((str(k), str(v)) for k, v in groups.items())) if groups else () + ) + attribution = _rule_attribution(details) + feature_flag_reported_key: Tuple[Any, ...] = (key, response, groups_key) + if attribution is not None: + feature_flag_reported_key += (2, *attribution) + + reported_flags = flag_calls_reported.get(distinct_id) + if reported_flags is None: + reported_flags = set() + flag_calls_reported[distinct_id] = reported_flags + + if feature_flag_reported_key not in reported_flags: + if attribution is not None: + properties.update(attribution.flag_called_properties()) + # Record the server's experiment signal when known, so minimization's impact + # can be measured by segmenting on it. + if has_experiment is not None: + properties["$feature_flag_has_experiment"] = has_experiment + + # Minimize iff the server-controlled gate is on AND the flag is known to have + # no linked experiment. Any missing signal (gate absent, has_experiment + # missing) fails safe to the full legacy shape. + should_minimize = minimal_flag_called_events and has_experiment is False + # Only thread the internal allowlist through when minimizing, so the + # full-property path's capture() call signature stays unchanged. + extra_capture_kwargs: Dict[str, Any] = {} + if should_minimize: + extra_capture_kwargs["_property_allowlist"] = ( + _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES + ) + + capture( + "$feature_flag_called", + distinct_id=distinct_id, + properties=properties, + groups=capture_groups, + disable_geoip=disable_geoip, + **extra_capture_kwargs, + ) + reported_flags.add(feature_flag_reported_key) + + if attribution is None or attribution.variant_key is None or not value_from_details: + return + + # The value, request ID, evaluation time and evaluation source are left out, so + # one assignment is one exposure however often it is read. + exposure_key = (key, groups_key, 2, attribution.rule_id, attribution.variant_key) + normalized_distinct_id = str(distinct_id) + reported_exposures = exposures_reported.get(normalized_distinct_id) + if reported_exposures is None: + reported_exposures = set() + exposures_reported[normalized_distinct_id] = reported_exposures + if exposure_key in reported_exposures: + return + + exposure_properties: Dict[str, Any] = { + name: properties[name] + for name in _EXPOSURE_PROPERTIES_FROM_FLAG_CALLED + if name in properties + } + exposure_properties.update( + { + "$feature_flag": key, + "$feature_flag_response": response, + "$feature_flag_config_version": 2, + "$feature_flag_variant": attribution.variant_key, + "$feature_flag_rule_id": attribution.rule_id, + "$feature_flag_rule_type": attribution.rule_type, + "$feature_flag_reason_code": attribution.reason_code, + "$experiment_exposure_source": "sdk", + "locally_evaluated": properties.get("locally_evaluated") is True, + } + ) + capture( + "$experiment_exposure", + distinct_id=distinct_id, + properties=exposure_properties, + groups=capture_groups, + disable_geoip=disable_geoip, + ) + reported_exposures.add(exposure_key) + + class _Lane: """A capture lane: a queue drained by its own consumer pool, posting to one endpoint. @@ -926,6 +1156,9 @@ def __init__( ) self.poller: Optional[Poller] = None self.distinct_ids_feature_flags_reported = SizeLimitedDict(MAX_DICT_SIZE, set) + # $experiment_exposure dedupe, kept apart from $feature_flag_called's so that a + # reported call never suppresses its exposure. Same bound and eviction. + self._experiment_exposures_reported = SizeLimitedDict(MAX_DICT_SIZE, set) 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 @@ -3162,6 +3395,11 @@ def _shutdown_once(self, errors: list[Exception]) -> None: self.distinct_ids_feature_flags_reported.clear, errors, ) + self._run_lifecycle_cleanup( + "Failed to clear experiment exposure deduplication state on shutdown", + self._experiment_exposures_reported.clear, + errors, + ) if self.exception_capture: self._run_lifecycle_cleanup( @@ -4202,7 +4440,9 @@ def get_feature_flag_result( """ Get a FeatureFlagResult object which contains the flag result and payload for a key by evaluating locally or remotely depending on whether local evaluation is enabled and the flag can be locally evaluated. - This also captures the `$feature_flag_called` event unless `send_feature_flag_events` is `False`. + This also captures the `$feature_flag_called` event unless `send_feature_flag_events` is `False`, + and, when a remotely evaluated config version 2 flag assigns an experiment variant, the + `$experiment_exposure` event. Examples: ```python @@ -4507,6 +4747,11 @@ def _capture_feature_flag_called( disable_geoip=disable_geoip, has_experiment=has_experiment, minimal_flag_called_events=minimal_flag_called_events, + details=flag_details, + # A payload lookup for a caller's ``match_value``, or a stale cached value, + # reports a value this record did not supply, so it sends no exposure. + value_from_details=isinstance(flag_details, FeatureFlag) + and response == flag_details.get_value(), ) def _capture_feature_flag_called_if_needed( @@ -4520,66 +4765,33 @@ def _capture_feature_flag_called_if_needed( disable_geoip: Optional[bool] = None, has_experiment: Optional[bool] = None, minimal_flag_called_events: bool = False, + details: Optional[FeatureFlag] = None, + value_from_details: bool = True, ) -> None: - """Fire a ``$feature_flag_called`` event if the (distinct_id, flag, response, - groups) tuple hasn't already been reported on this client. Group context is - included so that group-scoped flags fire a separate event for each group a user - is evaluated under. Shared by the single-flag evaluation path and - ``FeatureFlagEvaluations.is_enabled() / get_flag()`` so both paths dedupe - identically. - - ``has_experiment`` is the server-reported signal for whether the flag is linked - to an experiment (``None`` when the server did not report it). When the - server-controlled gate is on and the flag is known non-experiment, the event is - trimmed to a strict allowlist; any missing signal sends the full legacy shape, - and experiment-linked flags keep the full set for exposure analysis. - - ``minimal_flag_called_events`` is the gate as observed by the evaluation that - produced ``response``, not a fresh read of client-wide state: both callers - resolve it themselves (the snapshot pins it at construction; the single-flag - path reads it from the specific local/remote source that produced the value) - so a concurrent poller refresh or another ``/flags`` call can't reshape an - event after the fact. - """ - groups_key = ( - tuple(sorted((str(k), str(v)) for k, v in groups.items())) if groups else () - ) - feature_flag_reported_key = (key, response, groups_key) - - reported_flags = self.distinct_ids_feature_flags_reported.get(distinct_id) - if reported_flags is None: - reported_flags = set() - self.distinct_ids_feature_flags_reported[distinct_id] = reported_flags - - if feature_flag_reported_key in reported_flags: - return - - # Record the server's experiment signal when known, so minimization's impact - # can be measured by segmenting on it. - if has_experiment is not None: - properties["$feature_flag_has_experiment"] = has_experiment - - # Minimize iff the server-controlled gate is on AND the flag is known to have - # no linked experiment. Any missing signal (gate absent, has_experiment - # missing) fails safe to the full legacy shape. - should_minimize = minimal_flag_called_events and has_experiment is False - # Only thread the internal allowlist through when minimizing, so the - # full-property path's capture() call signature stays unchanged. - extra_capture_kwargs: dict[str, Any] = {} - if should_minimize: - extra_capture_kwargs["_property_allowlist"] = ( - _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES - ) + """Send the flag events of a flag read: ``$feature_flag_called`` and, for an + experiment split, ``$experiment_exposure``. See ``_capture_flag_events``. - self.capture( - "$feature_flag_called", + Shared by the single-flag evaluation path and the reads of a + ``FeatureFlagEvaluations`` snapshot, so every read reports and dedupes + identically. ``details`` is the parsed ``/flags`` record of the read, or + ``None`` for a locally evaluated flag. + """ + _capture_flag_events( + self.capture, + self.distinct_ids_feature_flags_reported, + self._experiment_exposures_reported, distinct_id=distinct_id, + key=key, + response=response, properties=properties, - groups=groups or {}, + groups=groups, + capture_groups=groups or {}, disable_geoip=disable_geoip, - **extra_capture_kwargs, + has_experiment=has_experiment, + minimal_flag_called_events=minimal_flag_called_events, + details=details, + value_from_details=value_from_details, ) - reported_flags.add(feature_flag_reported_key) def get_remote_config_payload(self, key: str): """ diff --git a/posthog/feature_flag_evaluations.py b/posthog/feature_flag_evaluations.py index ef4e401d9..fe8187d16 100644 --- a/posthog/feature_flag_evaluations.py +++ b/posthog/feature_flag_evaluations.py @@ -62,6 +62,11 @@ class FeatureFlagEvaluations: To narrow the set of flags that get attached to a captured event, use the in-memory helpers :meth:`only` and :meth:`only_accessed`. To narrow the set of flags requested from the server in the first place, pass ``flag_keys`` to :meth:`Client.evaluate_flags`. + + Reading a flag sends ``$feature_flag_called``, deduplicated as :meth:`is_enabled` + describes. When the flag was evaluated remotely and its config version 2 record + assigns an experiment variant, the read also sends ``$experiment_exposure``, once + per assignment. """ def __init__( @@ -305,4 +310,7 @@ def _record_access(self, key: str) -> None: properties=properties, has_experiment=flag.has_experiment if flag else None, minimal_flag_called_events=self._minimal_flag_called_events, + # The record that supplied the value. A remotely evaluated experiment + # split also sends $experiment_exposure, whatever this read returned. + details=flag.details if flag else None, ) diff --git a/posthog/test/test_experiment_exposure.py b/posthog/test/test_experiment_exposure.py new file mode 100644 index 000000000..be6bff31a --- /dev/null +++ b/posthog/test/test_experiment_exposure.py @@ -0,0 +1,989 @@ +"""``$experiment_exposure`` and the attribution of ``$feature_flag_called``. + +A remotely evaluated config version 2 record that assigns an experiment variant sends +``$experiment_exposure`` next to its ``$feature_flag_called``, and every config version +2 record adds its rule attribution to ``$feature_flag_called``. The reader fixtures and +event schemas are copied unchanged from a released ``PostHog/posthog-sdk-test-harness`` +contract; ``fixtures/feature_flag_rules_v2/SOURCE.json`` records which. +""" + +from __future__ import annotations + +import copy +import json +import warnings +from dataclasses import dataclass +from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional +from unittest import mock + +import jsonschema +import pytest + +from posthog import AsyncPosthog +from posthog.client import _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES, Client +from posthog.test.test_flags_v4_response import FIXTURES, READERS, _build +from posthog.test.test_utils import FAKE_TEST_API_KEY + +FLAG = "pricing-page" +DISTINCT_ID = "visitor-42" +RULE = "7f3c2a10-8d4e-4b6a-9c21-5e0f1a2b3c4d" +OTHER_RULE = "2b9d4e61-0c7a-4f35-8e12-6a4b3c2d1e0f" +REQUEST_ID = "5d1e2f30-4a5b-4c6d-8e7f-90a1b2c3d4e5" +OTHER_REQUEST_ID = "6e2f3a41-5b6c-4d7e-9f80-a1b2c3d4e5f6" +EVALUATED_AT = 1_800_000_000_000 + +EXPOSURE = "$experiment_exposure" +FLAG_CALLED = "$feature_flag_called" +ATTRIBUTION_PROPERTIES = ( + "$feature_flag_config_version", + "$feature_flag_reason_code", + "$feature_flag_rule_id", + "$feature_flag_rule_type", + "$feature_flag_variant", +) + + +def _schema(name: str) -> jsonschema.Draft202012Validator: + schema = json.loads((FIXTURES / name).read_text(encoding="utf-8")) + return jsonschema.Draft202012Validator(schema) + + +FLAG_CALLED_SCHEMA = _schema("feature_flag_called_context.schema.json") +EXPOSURE_SCHEMA = _schema("experiment_exposure_properties.schema.json") + + +def _projection( + validator: jsonschema.Draft202012Validator, properties: Mapping[str, Any] +) -> Dict[str, Any]: + """The properties a closed contract projection covers.""" + names = validator.schema["properties"] + return {key: value for key, value in properties.items() if key in names} + + +# --- Responses ---------------------------------------------------------------- + + +def _record( + code: str, + value: Any = True, + *, + key: str = FLAG, + rule_type: Optional[str] = None, + rule_id: Optional[str] = None, + variant_key: Optional[str] = None, + config_version: int = 2, + has_experiment: bool = False, + description: Optional[str] = None, +) -> Dict[str, Any]: + metadata: Dict[str, Any] = { + "id": 77, + "version": 3, + "config_version": config_version, + "description": None, + "payload": None, + "has_experiment": has_experiment, + } + for field, field_value in ( + ("rule_type", rule_type), + ("rule_id", rule_id), + ("variant_key", variant_key), + ): + if field_value is not None: + metadata[field] = field_value + reason: Dict[str, Any] = { + "code": code, + "condition_index": 0 if rule_id else None, + } + if description is not None: + reason["description"] = description + return {"key": key, "value": value, "reason": reason, "metadata": metadata} + + +def _split( + value: Any = "annual", + variant_key: str = "annual-first", + rule_id: str = RULE, + **overrides: Any, +) -> Dict[str, Any]: + fields: Dict[str, Any] = { + "rule_type": "experiment", + "rule_id": rule_id, + "variant_key": variant_key, + "has_experiment": True, + **overrides, + } + return _record("experiment_split", value, **fields) + + +def _response( + *records: Dict[str, Any], + request_id: str = REQUEST_ID, + evaluated_at: int = EVALUATED_AT, + **envelope: Any, +) -> Dict[str, Any]: + return { + "flags": {record["key"]: record for record in records}, + "errorsWhileComputingFlags": False, + "requestId": request_id, + "evaluatedAt": evaluated_at, + **envelope, + } + + +# --- Recording events ----------------------------------------------------------- + + +@dataclass +class _Event: + name: str + distinct_id: Any + properties: Dict[str, Any] + groups: Any + property_allowlist: Any + uuid: Optional[str] + + +def _record_events(client: Any) -> tuple[Any, List[_Event]]: + """Patch ``client.capture`` to record each call, then capture as usual.""" + events: List[_Event] = [] + capture = client.capture + + def record(event: str, **kwargs: Any) -> Optional[str]: + uuid = capture(event, **kwargs) + events.append( + _Event( + name=event, + distinct_id=kwargs.get("distinct_id"), + properties=copy.deepcopy(kwargs.get("properties") or {}), + groups=kwargs.get("groups"), + property_allowlist=kwargs.get("_property_allowlist"), + uuid=uuid, + ) + ) + return uuid + + return mock.patch.object(client, "capture", side_effect=record), events + + +def _named(events: List[_Event], name: str, key: str = FLAG) -> List[_Event]: + return [ + event + for event in events + if event.name == name and event.properties.get("$feature_flag") == key + ] + + +def _attribution(properties: Mapping[str, Any]) -> Dict[str, Any]: + return { + name: properties[name] for name in ATTRIBUTION_PROPERTIES if name in properties + } + + +# --- Reading through each path ------------------------------------------------------ + +# Every sync read that sends flag events. Each takes the client, the key, the +# distinct ID and the groups. +SYNC_READS: Dict[str, Callable[..., Any]] = { + "get_feature_flag": lambda client, key, distinct_id, groups: ( + client.get_feature_flag(key, distinct_id, groups=groups) + ), + "feature_enabled": lambda client, key, distinct_id, groups: ( + client.feature_enabled(key, distinct_id, groups=groups) + ), + "get_feature_flag_result": lambda client, key, distinct_id, groups: ( + client.get_feature_flag_result(key, distinct_id, groups=groups) + ), + "snapshot.get_flag": lambda client, key, distinct_id, groups: ( + client.evaluate_flags(distinct_id, groups=groups).get_flag(key) + ), + "snapshot.is_enabled": lambda client, key, distinct_id, groups: ( + client.evaluate_flags(distinct_id, groups=groups).is_enabled(key) + ), +} +SINGLE_FLAG_READS = ["get_feature_flag", "feature_enabled", "get_feature_flag_result"] +SNAPSHOT_READS = ["snapshot.get_flag", "snapshot.is_enabled"] + + +@pytest.fixture +def client() -> Iterator[Client]: + client = Client(FAKE_TEST_API_KEY, send=False) + yield client + client.shutdown() + + +def _serving(*responses: Dict[str, Any]) -> Any: + """Patch ``/flags`` to answer with each response in turn, the last one after that.""" + remaining = list(responses) + + def answer(*args: Any, **kwargs: Any) -> Dict[str, Any]: + response = remaining.pop(0) if len(remaining) > 1 else remaining[0] + return copy.deepcopy(response) + + return mock.patch("posthog.client.flags", side_effect=answer) + + +def _read( + client: Client, + path: str, + key: str = FLAG, + distinct_id: Any = DISTINCT_ID, + groups: Optional[Dict[str, Any]] = None, +) -> Any: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + return SYNC_READS[path](client, key, distinct_id, groups or {}) + + +def _read_events( + path: str, + *responses: Dict[str, Any], + reads: int = 1, + client: Optional[Client] = None, + **read_kwargs: Any, +) -> List[_Event]: + """Read ``FLAG`` ``reads`` times through ``path``, one response per read.""" + own_client = client is None + client = client or Client(FAKE_TEST_API_KEY, send=False) + recording, events = _record_events(client) + try: + with _serving(*responses), recording: + for _ in range(reads): + _read(client, path, **read_kwargs) + finally: + if own_client: + client.shutdown() + return events + + +async def _read_async_events( + responses: List[Dict[str, Any]], + reads: List[Callable[[Any], Any]], +) -> List[_Event]: + """Run the reads on one async client, answering each evaluation with the next + response, and the last one after that.""" + remaining = list(responses) + + def answer(*args: Any, **kwargs: Any) -> Dict[str, Any]: + response = remaining.pop(0) if len(remaining) > 1 else remaining[0] + return copy.deepcopy(response) + + with mock.patch( + "posthog.async_client._async_flags", new=mock.AsyncMock(side_effect=answer) + ): + client = AsyncPosthog("project-key", send=False) + recording, events = _record_events(client) + with recording: + for read in reads: + await read(client) + await client.shutdown() + return events + + +# --- The contract's reader cases -------------------------------------------------------- + +READER_CASES = [case for case in READERS["cases"] if case["expected"] == "reader"] + + +def _check_reader_events(case: Dict[str, Any], events: List[_Event]) -> None: + reader = case["reader"] + expectations = {reader["requested_key"]: reader} + expectations.update(case.get("sibling_expectations", {})) + for key, expected in expectations.items(): + exposures = _named(events, EXPOSURE, key) + assert len(exposures) == (1 if expected["experiment_exposure"] else 0), key + [called] = _named(events, FLAG_CALLED, key) + config_version = expected.get("config_version") + if expected.get("error_code") == "PARSE_ERROR": + config_version = None + assert called.properties.get("$feature_flag_config_version") == ( + 2 if config_version == 2 else None + ), key + if called.properties["$feature_flag_response"] is not None: + FLAG_CALLED_SCHEMA.validate( + _projection(FLAG_CALLED_SCHEMA, called.properties) + ) + for exposure in exposures: + EXPOSURE_SCHEMA.validate(_projection(EXPOSURE_SCHEMA, exposure.properties)) + assert ( + exposure.properties["$feature_flag_response"] + == (called.properties["$feature_flag_response"]) + ) + assert exposure.uuid is not None and exposure.uuid != called.uuid + for event in [called, *exposures]: + # A flag may be named anything, so `$feature/` is left out. + for name in event.properties: + if not name.startswith("$feature/"): + for part in ("experiment_id", "holdout", "seed"): + assert part not in name, name + + +@pytest.mark.parametrize("path", list(SYNC_READS)) +@pytest.mark.parametrize("case", READER_CASES, ids=lambda case: case["id"]) +def test_reader_cases_send_an_exposure_exactly_when_the_contract_expects(case, path): + response = _build(READERS, case) + client = Client(FAKE_TEST_API_KEY, send=False) + recording, events = _record_events(client) + try: + with _serving(response), recording: + _read(client, path, case["reader"]["requested_key"]) + for sibling in case.get("sibling_expectations", {}): + _read(client, path, sibling) + finally: + client.shutdown() + + _check_reader_events(case, events) + + +@pytest.mark.parametrize("method", ["get_flag", "is_enabled"]) +@pytest.mark.parametrize("case", READER_CASES, ids=lambda case: case["id"]) +async def test_async_reader_cases_send_an_exposure_exactly_when_the_contract_expects( + case, method +): + keys = [case["reader"]["requested_key"], *case.get("sibling_expectations", {})] + + async def read(client: Any) -> None: + snapshot = await client.evaluate_flags(DISTINCT_ID) + for key in keys: + getattr(snapshot, method)(key) + + events = await _read_async_events([_build(READERS, case)], [read]) + + _check_reader_events(case, events) + + +# --- Attribution on $feature_flag_called -------------------------------------------------- + +_RULE_ID = "10000000-0000-4000-8000-000000000001" +ATTRIBUTION_CASES = [ + # (reader template or case id, attribution on $feature_flag_called) + ( + "experiment_split", + { + "$feature_flag_config_version": 2, + "$feature_flag_reason_code": "experiment_split", + "$feature_flag_rule_id": _RULE_ID, + "$feature_flag_rule_type": "experiment", + "$feature_flag_variant": "arm_a", + }, + ), + ( + "targeting_match_targeted_release", + { + "$feature_flag_config_version": 2, + "$feature_flag_reason_code": "targeting_match", + "$feature_flag_rule_id": _RULE_ID, + "$feature_flag_rule_type": "targeted_release", + }, + ), + ( + "targeting_match_percentage_rollout", + { + "$feature_flag_config_version": 2, + "$feature_flag_reason_code": "targeting_match", + "$feature_flag_rule_id": _RULE_ID, + "$feature_flag_rule_type": "percentage_rollout", + }, + ), + ( + "rollout_miss_percentage_rollout", + { + "$feature_flag_config_version": 2, + "$feature_flag_reason_code": "rollout_miss", + "$feature_flag_rule_id": _RULE_ID, + "$feature_flag_rule_type": "percentage_rollout", + }, + ), + ( + "rollout_miss_experiment", + { + "$feature_flag_config_version": 2, + "$feature_flag_reason_code": "rollout_miss", + "$feature_flag_rule_id": _RULE_ID, + "$feature_flag_rule_type": "experiment", + }, + ), + ( + "experiment_paused", + { + "$feature_flag_config_version": 2, + "$feature_flag_reason_code": "experiment_paused", + "$feature_flag_rule_id": _RULE_ID, + "$feature_flag_rule_type": "experiment", + }, + ), + ( + "holdout", + { + "$feature_flag_config_version": 2, + "$feature_flag_reason_code": "holdout", + "$feature_flag_rule_id": _RULE_ID, + "$feature_flag_rule_type": "experiment", + }, + ), + # A holdout assigns no variant, even when its record names one. + ( + "readers.holdout_variant_key", + { + "$feature_flag_config_version": 2, + "$feature_flag_reason_code": "holdout", + "$feature_flag_rule_id": _RULE_ID, + "$feature_flag_rule_type": "experiment", + }, + ), + ( + "no_rule_match", + { + "$feature_flag_config_version": 2, + "$feature_flag_reason_code": "no_rule_match", + }, + ), + ( + "missing_group_key", + { + "$feature_flag_config_version": 2, + "$feature_flag_reason_code": "missing_group_key", + }, + ), + ( + "dependency_error", + { + "$feature_flag_config_version": 2, + "$feature_flag_reason_code": "dependency_error", + }, + ), + ( + "error", + {"$feature_flag_config_version": 2, "$feature_flag_reason_code": "error"}, + ), + # A malformed split keeps its config version and reason, and loses the whole rule + # tuple. + *( + ( + case_id, + { + "$feature_flag_config_version": 2, + "$feature_flag_reason_code": "experiment_split", + }, + ) + for case_id in ( + "readers.partial_split_missing_rule_type", + "readers.partial_split_missing_rule_id", + "readers.partial_split_missing_variant_key", + "readers.wrong_rule_type_literal", + "readers.partial_split_string_value", + ) + ), + # Config version 1, from either kind of server, is unchanged. + ("v1", {}), + ("old_server", {}), + ("readers.partial_split_missing_config_version", {}), +] + + +def _attribution_response(source: str) -> Dict[str, Any]: + if source.startswith("readers."): + [case] = [case for case in READERS["cases"] if case["id"] == source] + return _build(READERS, case) + return _build(READERS, {"template": source}) + + +@pytest.mark.parametrize("path", ["get_feature_flag_result", "snapshot.get_flag"]) +@pytest.mark.parametrize( + "source, expected", ATTRIBUTION_CASES, ids=[c[0] for c in ATTRIBUTION_CASES] +) +def test_flag_called_attribution_follows_the_record(source, expected, path): + events = _read_events(path, _attribution_response(source), key="garden-layout") + + [called] = _named(events, FLAG_CALLED, "garden-layout") + assert _attribution(called.properties) == expected + exposures = _named(events, EXPOSURE, "garden-layout") + assert len(exposures) == (1 if "$feature_flag_variant" in expected else 0) + + +def test_an_older_server_record_reports_no_attribution(): + # A record without a typed value comes from a server before /flags?v=4. Its + # metadata is not read as config version 2, whatever it says. + response = _response( + { + "key": FLAG, + "enabled": True, + "variant": "annual", + "reason": {"code": "condition_match", "condition_index": 0}, + "metadata": { + "id": 77, + "version": 3, + "config_version": 2, + "rule_type": "experiment", + "rule_id": RULE, + "variant_key": "annual-first", + }, + } + ) + for path in SYNC_READS: + events = _read_events(path, response) + [called] = _named(events, FLAG_CALLED) + assert _attribution(called.properties) == {} + assert _named(events, EXPOSURE) == [] + + +# --- The exact events of a split --------------------------------------------------------- + + +EXPECTED_EXPOSURE = { + "$feature_flag": FLAG, + "$feature_flag_response": "annual", + "$feature_flag_config_version": 2, + "$feature_flag_variant": "annual-first", + "$feature_flag_rule_id": RULE, + "$feature_flag_rule_type": "experiment", + "$feature_flag_reason_code": "experiment_split", + "$experiment_exposure_source": "sdk", + "locally_evaluated": False, + "$feature_flag_id": 77, + "$feature_flag_version": 3, + "$feature_flag_request_id": REQUEST_ID, + "$feature_flag_evaluated_at": EVALUATED_AT, +} + + +@pytest.mark.parametrize("path", list(SYNC_READS)) +def test_a_split_sends_both_events_with_exact_properties(path): + response = _response(_split(description="Assigned variant 'annual-first'")) + + events = _read_events(path, response, groups={"company": "company-7"}) + + assert [event.name for event in events] == [FLAG_CALLED, EXPOSURE] + called, exposure = events + assert called.properties == { + "$feature_flag": FLAG, + "$feature_flag_response": "annual", + "locally_evaluated": False, + f"$feature/{FLAG}": "annual", + "$feature_flag_request_id": REQUEST_ID, + "$feature_flag_evaluated_at": EVALUATED_AT, + "$feature_flag_reason": "Assigned variant 'annual-first'", + "$feature_flag_version": 3, + "$feature_flag_id": 77, + "$feature_flag_has_experiment": True, + "$feature_flag_config_version": 2, + "$feature_flag_reason_code": "experiment_split", + "$feature_flag_rule_id": RULE, + "$feature_flag_rule_type": "experiment", + "$feature_flag_variant": "annual-first", + } + assert exposure.properties == EXPECTED_EXPOSURE + assert exposure.distinct_id == called.distinct_id == DISTINCT_ID + assert exposure.groups == called.groups == {"company": "company-7"} + assert exposure.property_allowlist is None + + +async def test_async_split_sends_both_events_with_exact_properties(): + async def read(client: Any) -> None: + snapshot = await client.evaluate_flags( + DISTINCT_ID, groups={"company": 7}, disable_geoip=True + ) + snapshot.get_flag(FLAG) + + events = await _read_async_events([_response(_split())], [read]) + + assert [event.name for event in events] == [FLAG_CALLED, EXPOSURE] + called, exposure = events + assert exposure.properties == EXPECTED_EXPOSURE + assert _attribution(called.properties) == _attribution(EXPECTED_EXPOSURE) + # The async client sends group keys as strings. + assert exposure.groups == called.groups == {"company": "7"} + assert exposure.distinct_id == called.distinct_id == DISTINCT_ID + + +def test_the_pair_shares_capture_identity_but_not_its_uuid(): + messages: List[Dict[str, Any]] = [] + + def before_send(message: Dict[str, Any]) -> Dict[str, Any]: + messages.append(message) + return message + + client = Client(FAKE_TEST_API_KEY, send=False, before_send=before_send) + try: + with _serving(_response(_split())): + snapshot = client.evaluate_flags( + DISTINCT_ID, groups={"company": "company-7"}, disable_geoip=True + ) + assert snapshot.get_flag(FLAG) == "annual" + finally: + client.shutdown() + + called, exposure = messages + assert (called["event"], exposure["event"]) == (FLAG_CALLED, EXPOSURE) + assert called["uuid"] and exposure["uuid"] and called["uuid"] != exposure["uuid"] + assert exposure["distinct_id"] == called["distinct_id"] == DISTINCT_ID + for name in ("$groups", "$lib", "$lib_version", "$geoip_disable"): + assert exposure["properties"][name] == called["properties"][name], name + assert exposure["properties"]["$groups"] == {"company": "company-7"} + + +@pytest.mark.parametrize("path", SNAPSHOT_READS) +def test_an_assignment_is_reported_whatever_the_read_returns(path): + # An experiment arm whose value is false still assigned the visitor to that arm. + response = _response(_split(value=False, variant_key="no-banner")) + + events = _read_events(path, response) + + [exposure] = _named(events, EXPOSURE) + assert exposure.properties["$feature_flag_response"] is False + assert exposure.properties["$feature_flag_variant"] == "no-banner" + + +# --- What sends no exposure ------------------------------------------------------------ + + +@pytest.mark.parametrize("path", SINGLE_FLAG_READS) +def test_opting_out_of_flag_events_suppresses_both(client, path): + recording, events = _record_events(client) + with _serving(_response(_split())), recording, warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + getattr(client, path)(FLAG, DISTINCT_ID, send_feature_flag_events=False) + + assert events == [] + + +def test_a_payload_read_sends_no_flag_events(client): + recording, events = _record_events(client) + with _serving(_response(_split())), recording, warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + client.get_feature_flag_payload(FLAG, DISTINCT_ID) + snapshot = client.evaluate_flags(DISTINCT_ID) + snapshot.get_flag_payload(FLAG) + + assert events == [] + + +def test_a_payload_read_that_sends_flag_events_also_sends_the_exposure(client): + recording, events = _record_events(client) + with _serving(_response(_split())), recording, warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + client.get_feature_flag_payload( + FLAG, DISTINCT_ID, send_feature_flag_events=True + ) + + assert [event.name for event in events] == [FLAG_CALLED, EXPOSURE] + + +def test_a_payload_lookup_for_another_value_sends_no_exposure(client): + # The event reports the caller's match_value, which the record did not supply. + recording, events = _record_events(client) + with _serving(_response(_split())), recording, warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + client.get_feature_flag_payload( + FLAG, DISTINCT_ID, match_value="monthly", send_feature_flag_events=True + ) + + [called] = events + assert called.name == FLAG_CALLED + assert called.properties["$feature_flag_response"] == "monthly" + assert called.properties["$feature_flag_config_version"] == 2 + + +@pytest.mark.parametrize("path", list(SYNC_READS)) +def test_a_locally_evaluated_flag_is_unchanged(path): + client = Client(FAKE_TEST_API_KEY, send=False, secret_key="secret-key") + client.feature_flags = [ + { + "id": 9, + "name": "Pricing page", + "key": FLAG, + "active": True, + "filters": { + "groups": [{"properties": [], "rollout_percentage": 100}], + "multivariate": { + "variants": [{"key": "annual", "rollout_percentage": 100}] + }, + }, + } + ] + recording, events = _record_events(client) + try: + with _serving(_response(_split())) as remote, recording: + _read(client, path) + finally: + client.shutdown() + + assert remote.call_count == 0 + [called] = events + assert called.name == FLAG_CALLED + assert called.properties["locally_evaluated"] is True + assert _attribution(called.properties) == {} + + +def test_an_empty_snapshot_sends_nothing(client): + recording, events = _record_events(client) + with _serving(_response(_split())), recording: + snapshot = client.evaluate_flags() + snapshot.get_flag(FLAG) + snapshot.is_enabled(FLAG) + + assert events == [] + + +def test_a_failed_exposure_capture_does_not_change_the_read(): + def before_send(message: Dict[str, Any]) -> Dict[str, Any]: + if message["event"] == EXPOSURE: + raise ValueError("rejected") + return message + + client = Client(FAKE_TEST_API_KEY, send=False, before_send=before_send) + try: + with _serving(_response(_split())), warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + assert client.get_feature_flag(FLAG, DISTINCT_ID) == "annual" + assert client.evaluate_flags(DISTINCT_ID).get_flag(FLAG) == "annual" + finally: + client.shutdown() + + +# --- Dedupe ------------------------------------------------------------------------ + + +@pytest.mark.parametrize( + "first, second", + [ + ("get_feature_flag", "get_feature_flag"), + ("snapshot.get_flag", "snapshot.is_enabled"), + ("feature_enabled", "snapshot.get_flag"), + ("snapshot.get_flag", "get_feature_flag_result"), + ], +) +def test_a_repeated_read_sends_nothing_new(client, first, second): + # The second evaluation has a new request ID and time, which neither event's + # dedupe reads. + later = _response( + _split(), request_id=OTHER_REQUEST_ID, evaluated_at=EVALUATED_AT + 1 + ) + recording, events = _record_events(client) + with _serving(_response(_split()), later), recording: + _read(client, first) + _read(client, second) + + assert [event.name for event in events] == [FLAG_CALLED, EXPOSURE] + + +@pytest.mark.parametrize("path", list(SYNC_READS)) +def test_equal_valued_arms_are_each_reported(path): + # An A/A experiment: both arms return the same value. + events = _read_events( + path, + _response(_split(value=True, variant_key="control")), + _response(_split(value=True, variant_key="test")), + reads=2, + ) + + exposures = _named(events, EXPOSURE) + assert [e.properties["$feature_flag_variant"] for e in exposures] == [ + "control", + "test", + ] + assert len(_named(events, FLAG_CALLED)) == 2 + + +@pytest.mark.parametrize("path", list(SYNC_READS)) +def test_a_later_experiment_on_the_flag_is_reported_again(path): + events = _read_events( + path, + _response(_split(rule_id=RULE)), + _response(_split(rule_id=OTHER_RULE)), + reads=2, + ) + + exposures = _named(events, EXPOSURE) + assert [e.properties["$feature_flag_rule_id"] for e in exposures] == [ + RULE, + OTHER_RULE, + ] + assert len(_named(events, FLAG_CALLED)) == 2 + + +@pytest.mark.parametrize("path", list(SYNC_READS)) +def test_the_exposure_dedupe_leaves_out_the_value(path): + # The arm's value changed, but the visitor is still in the same arm. + events = _read_events( + path, + _response(_split(value="annual")), + _response(_split(value="annual-v2")), + reads=2, + ) + + assert len(_named(events, FLAG_CALLED)) == 2 + assert len(_named(events, EXPOSURE)) == 1 + + +@pytest.mark.parametrize("path", list(SYNC_READS)) +def test_a_holdout_and_a_split_of_one_rule_are_both_reported(path): + holdout = _record( + "holdout", False, rule_type="experiment", rule_id=RULE, has_experiment=True + ) + events = _read_events( + path, _response(holdout), _response(_split(value=False)), reads=2 + ) + + called = _named(events, FLAG_CALLED) + assert [e.properties["$feature_flag_reason_code"] for e in called] == [ + "holdout", + "experiment_split", + ] + assert len(_named(events, EXPOSURE)) == 1 + + +def test_groups_are_part_of_the_exposure_identity(client): + recording, events = _record_events(client) + with _serving(_response(_split())), recording: + _read(client, "snapshot.get_flag", groups={"company": "a", "team": "b"}) + _read(client, "get_feature_flag", groups={"team": "b", "company": "a"}) + _read(client, "snapshot.get_flag", groups={"company": "c"}) + + exposures = _named(events, EXPOSURE) + assert [e.groups for e in exposures] == [ + {"company": "a", "team": "b"}, + {"company": "c"}, + ] + + +def test_the_exposure_dedupe_normalizes_the_distinct_id(client): + recording, events = _record_events(client) + with _serving(_response(_split())), recording: + _read(client, "get_feature_flag_result", distinct_id=42) + _read(client, "snapshot.get_flag", distinct_id="42") + + assert len(_named(events, EXPOSURE)) == 1 + + +def test_a_reported_call_never_suppresses_its_exposure(client): + # A read whose value the record did not supply reports the call without an + # exposure. A later read of the same record still sends the exposure, although + # its call is a duplicate. + with _serving(_response(_split())): + details = client.evaluate_flags(DISTINCT_ID)._flags[FLAG].details + recording, events = _record_events(client) + with recording: + for value_from_details in (False, True): + client._capture_feature_flag_called_if_needed( + distinct_id=DISTINCT_ID, + key=FLAG, + response="annual", + properties={"$feature_flag": FLAG, "$feature_flag_response": "annual"}, + details=details, + value_from_details=value_from_details, + ) + + assert [event.name for event in events] == [FLAG_CALLED, EXPOSURE] + + +async def test_async_a_reported_call_never_suppresses_its_exposure(): + async def read(client: Any) -> None: + snapshot = await client.evaluate_flags(DISTINCT_ID) + details = snapshot._flags[FLAG].details + for value_from_details in (False, True): + client._capture_feature_flag_called_if_needed( + distinct_id=DISTINCT_ID, + key=FLAG, + response="annual", + properties={"$feature_flag": FLAG, "$feature_flag_response": "annual"}, + details=details, + value_from_details=value_from_details, + ) + + events = await _read_async_events([_response(_split())], [read]) + + assert [event.name for event in events] == [FLAG_CALLED, EXPOSURE] + + +async def test_async_snapshots_dedupe_both_events(): + async def read(client: Any) -> None: + snapshot = await client.evaluate_flags(DISTINCT_ID) + snapshot.get_flag(FLAG) + snapshot.is_enabled(FLAG) + snapshot.only([FLAG]).get_flag(FLAG) + + events = await _read_async_events( + [ + _response(_split(variant_key="control", value=True)), + _response(_split(variant_key="control", value=True)), + _response(_split(variant_key="test", value=True)), + ], + [read, read, read], + ) + + assert [e.properties["$feature_flag_variant"] for e in events] == [ + "control", + "control", + "test", + "test", + ] + assert [e.name for e in events] == [FLAG_CALLED, EXPOSURE] * 2 + + +def test_the_v1_dedupe_key_is_unchanged(client): + with _serving(_build(READERS, {"template": "v1"})): + _read(client, "get_feature_flag", "garden-layout") + + assert client.distinct_ids_feature_flags_reported[DISTINCT_ID] == { + ("garden-layout", "compact", ()) + } + assert len(client._experiment_exposures_reported) == 0 + + +def test_shutdown_clears_the_exposure_dedupe(): + client = Client(FAKE_TEST_API_KEY, send=False) + with _serving(_response(_split())): + _read(client, "snapshot.get_flag") + assert len(client._experiment_exposures_reported) == 1 + + client.shutdown() + + assert len(client._experiment_exposures_reported) == 0 + + +async def test_async_shutdown_clears_the_exposure_dedupe(): + with mock.patch( + "posthog.async_client._async_flags", + new=mock.AsyncMock(return_value=_response(_split())), + ): + client = AsyncPosthog("project-key", send=False) + (await client.evaluate_flags(DISTINCT_ID)).get_flag(FLAG) + assert len(client._experiment_exposures_reported) == 1 + await client.shutdown() + + assert len(client._experiment_exposures_reported) == 0 + + +# --- Minimal events ------------------------------------------------------------------- + + +def test_minimal_flag_called_events_keep_the_attribution(): + messages: List[Dict[str, Any]] = [] + + def before_send(message: Dict[str, Any]) -> Dict[str, Any]: + messages.append(message) + return message + + client = Client( + FAKE_TEST_API_KEY, + send=False, + before_send=before_send, + super_properties={"plan": "team"}, + ) + # A split of an experiment rule no experiment links to: the flag reports no + # experiment, so its $feature_flag_called is minimal. + response = _response(_split(has_experiment=False), minimalFlagCalledEvents=True) + try: + with _serving(response): + client.evaluate_flags(DISTINCT_ID).get_flag(FLAG) + finally: + client.shutdown() + + called, exposure = messages + assert set(called["properties"]) <= _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES + assert "plan" not in called["properties"] + assert _attribution(called["properties"]) == _attribution(EXPECTED_EXPOSURE) + # The exposure is a full event. + assert exposure["event"] == EXPOSURE + assert exposure["properties"]["plan"] == "team" + assert _attribution(exposure["properties"]) == _attribution(EXPECTED_EXPOSURE) diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py index a0b3011ab..097a019e9 100644 --- a/posthog/test/test_flags_v4_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -875,11 +875,25 @@ def _parity_response(shape: str) -> Dict[str, Any]: } +# A config version 2 record adds these to its flag events. A v2-shaped response has no +# config version 2 records, so they are left out of the comparison and checked in +# test_experiment_exposure.py. +_CONFIG_VERSION_2_EVENT_PROPERTIES = { + "$feature_flag_config_version", + "$feature_flag_reason_code", + "$feature_flag_rule_id", + "$feature_flag_rule_type", + "$feature_flag_variant", +} + + 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") + if ( + key.startswith("$feature") and key not in _CONFIG_VERSION_2_EVENT_PROPERTIES + ) or key in ("$active_feature_flags", "locally_evaluated") } From 5d8fa497ad751d00721f9a4847ee34cd0db978ee Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Fri, 30 Oct 2026 12:59:59 +0100 Subject: [PATCH 18/34] fix(flags): send rule fields only for reasons that name a rule $feature_flag_called for a config version 2 record now sends $feature_flag_rule_id and $feature_flag_rule_type only for a targeting match, a rollout miss, a paused experiment, a holdout and a complete experiment split. Any other reason code, including one the SDK does not know, and any failed record send neither, even when the record carries them. A failed experiment split therefore sends no exposure either. --- .sampo/changesets/experiment-exposure.md | 2 +- posthog/client.py | 59 +++++++++++------ posthog/test/test_experiment_exposure.py | 82 +++++++++++++++++++++++- 3 files changed, 121 insertions(+), 22 deletions(-) diff --git a/.sampo/changesets/experiment-exposure.md b/.sampo/changesets/experiment-exposure.md index 9bc510371..a3892a957 100644 --- a/.sampo/changesets/experiment-exposure.md +++ b/.sampo/changesets/experiment-exposure.md @@ -2,4 +2,4 @@ pypi/posthog: minor --- -Send `$experiment_exposure` when a remotely evaluated config version 2 flag assigns an experiment variant. The SDK sends it together with `$feature_flag_called`, from `get_feature_flag()`, `feature_enabled()`, `get_feature_flag_result()`, and the `is_enabled()` and `get_flag()` reads of an `evaluate_flags()` snapshot. It carries the flag key, the same `$feature_flag_response` as its `$feature_flag_called`, the rule ID, rule type and variant key, the reason code, `$experiment_exposure_source: "sdk"` and `locally_evaluated: false`, and the flag ID, version, request ID and evaluation time when the response has them. It is sent once per distinct ID, groups, flag, rule and variant, whatever value or request it came from. `$feature_flag_called` for a config version 2 flag also gets `$feature_flag_config_version`, `$feature_flag_reason_code`, and `$feature_flag_rule_id`, `$feature_flag_rule_type` and `$feature_flag_variant` when the flag has them. Minimal `$feature_flag_called` events keep these properties, and their dedupe includes them, so equal-valued arms and successive experiments of one flag are each reported. A split that lacks its rule type, rule ID or variant key sends no exposure and reports no rule or variant. `send_feature_flag_events=False` suppresses both events. Config version 1 flags, locally evaluated flags and `$feature_flag_response` are unchanged. +Send `$experiment_exposure` when a remotely evaluated config version 2 flag assigns an experiment variant. The SDK sends it together with `$feature_flag_called`, from `get_feature_flag()`, `feature_enabled()`, `get_feature_flag_result()`, and the `is_enabled()` and `get_flag()` reads of an `evaluate_flags()` snapshot. It carries the flag key, the same `$feature_flag_response` as its `$feature_flag_called`, the rule ID, rule type and variant key, the reason code, `$experiment_exposure_source: "sdk"` and `locally_evaluated: false`, and the flag ID, version, request ID and evaluation time when the response has them. It is sent once per distinct ID, groups, flag, rule and variant, whatever value or request it came from. `$feature_flag_called` for a config version 2 flag also gets `$feature_flag_config_version` and `$feature_flag_reason_code`. A targeting match, rollout miss, paused experiment or holdout adds the `$feature_flag_rule_id` and `$feature_flag_rule_type` it names, and a complete experiment split adds these and `$feature_flag_variant`. Other outcomes, including failed flags, add no rule properties. Minimal `$feature_flag_called` events keep these properties, and their dedupe includes them, so equal-valued arms and successive experiments of one flag are each reported. A split that lacks its rule type, rule ID or variant key sends no exposure and reports no rule or variant. `send_feature_flag_events=False` suppresses both events. Config version 1 flags, locally evaluated flags and `$feature_flag_response` are unchanged. diff --git a/posthog/client.py b/posthog/client.py index 7012abcce..4a205722d 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -384,6 +384,19 @@ def _metadata_has_experiment(metadata: Any) -> Optional[bool]: _EXPERIMENT_SPLIT_REASON = "experiment_split" _EXPERIMENT_RULE_TYPE = "experiment" +# The reason codes of a config version 2 record that report the rule that produced +# the result. Any other reason code, and any failed record, reports no rule, even if +# the record names one. +_RULE_REASON_CODES = frozenset( + { + "targeting_match", + _EXPERIMENT_SPLIT_REASON, + "rollout_miss", + "experiment_paused", + "holdout", + } +) + # ``$experiment_exposure`` copies these from the ``$feature_flag_called`` of the same # read, when it has them. _EXPOSURE_PROPERTIES_FROM_FLAG_CALLED = ( @@ -394,14 +407,15 @@ def _metadata_has_experiment(metadata: Any) -> Optional[bool]: ) -def _flag_record_metadata(metadata: FlagMetadata, name: str) -> Any: - """Read a ``/flags?v=4`` metadata field of a flag record, such as ``config_version``. +def _v4_field(record: Any, name: str) -> Any: + """Read a ``/flags?v=4`` field of a parsed flag record or its metadata, such as + ``failed`` or ``config_version``. - These fields stay private, as ``_config_version`` and so on, until the typed flag - accessors make them public under their wire names. Either spelling is read, so - the two changes can land in either order. + These fields stay private, as ``_failed``, ``_config_version`` and so on, until + the typed flag accessors make them public under their wire names. Either spelling + is read, so the two changes can land in either order. """ - return getattr(metadata, name, getattr(metadata, f"_{name}", None)) + return getattr(record, name, getattr(record, f"_{name}", None)) class _RuleAttribution(NamedTuple): @@ -429,29 +443,34 @@ def flag_called_properties(self) -> Dict[str, Any]: def _rule_attribution(details: Optional[FeatureFlag]) -> Optional[_RuleAttribution]: """Read the event attribution of a config version 2 flag record. - Returns ``None`` for anything else: no record, a config version 1 record, or a - record from a server without typed values. Only the evaluation details are read, - never the value a getter returned. - - The rule ID and rule type are reported when the record has them. The variant key - is reported only for an experiment split, and only when the split is complete: - reason ``experiment_split``, rule type ``experiment``, a rule ID and a variant - key. A split without all of them is malformed. It keeps its reason but reports no - rule ID, rule type or variant, and sends no exposure. Its identity is never - guessed from the value, the key or an earlier result. + Returns ``None`` for anything else: no record, an unreadable record, a config + version 1 record, or a record from a server without typed values. Only the + evaluation details are read, never the value a getter returned. + + The config version and reason code are always reported. The rule ID and rule + type are reported, when the record has them, only for a targeting match, a + rollout miss, a paused experiment, a holdout and a complete experiment split. A + failed record, or any other reason code, reports neither. The variant key is + reported only for a complete experiment split: reason ``experiment_split``, rule + type ``experiment``, a rule ID and a variant key. A split without all of them is + malformed. It keeps its reason but reports no rule ID, rule type or variant, and + sends no exposure. Its identity is never guessed from the value, the key or an + earlier result. """ if details is None or not isinstance(details.metadata, FlagMetadata): return None metadata = details.metadata - if _flag_record_metadata(metadata, "config_version") != 2: + if _v4_field(metadata, "config_version") != 2: return None reason_code = (details.reason.code if details.reason else None) or None - rule_id = _flag_record_metadata(metadata, "rule_id") or None - rule_type = _flag_record_metadata(metadata, "rule_type") or None - variant_key = _flag_record_metadata(metadata, "variant_key") or None + if _v4_field(details, "failed") is True or reason_code not in _RULE_REASON_CODES: + return _RuleAttribution(reason_code, None, None, None) + rule_id = _v4_field(metadata, "rule_id") or None + rule_type = _v4_field(metadata, "rule_type") or None if reason_code != _EXPERIMENT_SPLIT_REASON: # Only an experiment split assigns a variant. return _RuleAttribution(reason_code, rule_id, rule_type, None) + variant_key = _v4_field(metadata, "variant_key") or None if rule_type != _EXPERIMENT_RULE_TYPE or rule_id is None or variant_key is None: return _RuleAttribution(reason_code, None, None, None) return _RuleAttribution(reason_code, rule_id, rule_type, variant_key) diff --git a/posthog/test/test_experiment_exposure.py b/posthog/test/test_experiment_exposure.py index be6bff31a..1ab57f9f9 100644 --- a/posthog/test/test_experiment_exposure.py +++ b/posthog/test/test_experiment_exposure.py @@ -74,6 +74,7 @@ def _record( config_version: int = 2, has_experiment: bool = False, description: Optional[str] = None, + failed: bool = False, ) -> Dict[str, Any]: metadata: Dict[str, Any] = { "id": 77, @@ -96,7 +97,10 @@ def _record( } if description is not None: reason["description"] = description - return {"key": key, "value": value, "reason": reason, "metadata": metadata} + record = {"key": key, "value": value, "reason": reason, "metadata": metadata} + if failed: + record["failed"] = True + return record def _split( @@ -496,10 +500,86 @@ def test_flag_called_attribution_follows_the_record(source, expected, path): [called] = _named(events, FLAG_CALLED, "garden-layout") assert _attribution(called.properties) == expected + FLAG_CALLED_SCHEMA.validate(_projection(FLAG_CALLED_SCHEMA, called.properties)) exposures = _named(events, EXPOSURE, "garden-layout") assert len(exposures) == (1 if "$feature_flag_variant" in expected else 0) +# A record whose reason names no rule, or that failed, reports no rule even when it +# carries one. Neither sends an exposure. The last element says whether the record is +# a valid server outcome, which the contract's event schema then accepts. +STRAY_RULE_CASES = [ + ("no_rule_match", {}, True), + ("missing_group_key", {}, True), + ("flag_disabled", {}, True), + ("dependency_error", {"failed": True, "value": None}, True), + ("error", {"failed": True, "value": None}, True), + # A failed record reports no rule, whatever its reason code. + ( + "experiment_split", + {"failed": True, "value": None, "variant_key": "annual-first"}, + True, + ), + ("holdout", {"failed": True, "value": None}, False), + # A reason code this SDK does not know. + ("future_reason", {}, False), +] + + +@pytest.mark.parametrize("path", ["get_feature_flag_result", "snapshot.get_flag"]) +@pytest.mark.parametrize( + "code, fields, valid_outcome", + STRAY_RULE_CASES, + ids=[f"{c}-{sorted(f)}" for c, f, _ in STRAY_RULE_CASES], +) +def test_rule_fields_are_sent_only_for_reasons_that_name_a_rule( + code, fields, valid_outcome, path +): + fields = {"value": False, **fields} + record = _record( + code, rule_type="experiment", rule_id=RULE, has_experiment=True, **fields + ) + + events = _read_events(path, _response(record)) + + [called] = _named(events, FLAG_CALLED) + assert _attribution(called.properties) == { + "$feature_flag_config_version": 2, + "$feature_flag_reason_code": code, + } + assert _named(events, EXPOSURE) == [] + if valid_outcome: + FLAG_CALLED_SCHEMA.validate(_projection(FLAG_CALLED_SCHEMA, called.properties)) + + +@pytest.mark.parametrize("path", ["get_feature_flag_result", "snapshot.get_flag"]) +@pytest.mark.parametrize( + "code, rule_type", + [ + ("targeting_match", "targeted_release"), + ("targeting_match", "percentage_rollout"), + ("rollout_miss", "percentage_rollout"), + ("rollout_miss", "experiment"), + ("experiment_paused", "experiment"), + ("holdout", "experiment"), + ], +) +def test_rule_matches_misses_pauses_and_holdouts_name_their_rule(code, rule_type, path): + record = _record(code, False, rule_type=rule_type, rule_id=RULE) + + events = _read_events(path, _response(record)) + + [called] = _named(events, FLAG_CALLED) + assert _attribution(called.properties) == { + "$feature_flag_config_version": 2, + "$feature_flag_reason_code": code, + "$feature_flag_rule_id": RULE, + "$feature_flag_rule_type": rule_type, + } + assert _named(events, EXPOSURE) == [] + FLAG_CALLED_SCHEMA.validate(_projection(FLAG_CALLED_SCHEMA, called.properties)) + + def test_an_older_server_record_reports_no_attribution(): # A record without a typed value comes from a server before /flags?v=4. Its # metadata is not read as config version 2, whatever it says. From 52ea38aae269cc726b79019d7cead0555953b28f Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Fri, 30 Oct 2026 14:03:37 +0100 Subject: [PATCH 19/34] fix(flags): decide exposure from the record that supplied the value The single-flag path decided whether a read's record supplied its value by comparing the reported value with the record's. Equal values do not show where a value came from: a payload lookup whose match_value equals the record's value, such as True for a number or object arm, sent the exposure and reserved its dedupe key, so the real read sent none. The path now sets value_from_details only when the remote record supplied the value: not for a caller's match_value, and not for a stale cached value used after a failure. Snapshot reads pass it explicitly, and the capture helpers default to no exposure. --- .sampo/changesets/experiment-exposure.md | 2 +- posthog/async_client.py | 2 +- posthog/client.py | 20 +++++--- posthog/feature_flag_evaluations.py | 1 + posthog/test/test_experiment_exposure.py | 59 ++++++++++++++++++++---- 5 files changed, 68 insertions(+), 16 deletions(-) diff --git a/.sampo/changesets/experiment-exposure.md b/.sampo/changesets/experiment-exposure.md index a3892a957..38142a52a 100644 --- a/.sampo/changesets/experiment-exposure.md +++ b/.sampo/changesets/experiment-exposure.md @@ -2,4 +2,4 @@ pypi/posthog: minor --- -Send `$experiment_exposure` when a remotely evaluated config version 2 flag assigns an experiment variant. The SDK sends it together with `$feature_flag_called`, from `get_feature_flag()`, `feature_enabled()`, `get_feature_flag_result()`, and the `is_enabled()` and `get_flag()` reads of an `evaluate_flags()` snapshot. It carries the flag key, the same `$feature_flag_response` as its `$feature_flag_called`, the rule ID, rule type and variant key, the reason code, `$experiment_exposure_source: "sdk"` and `locally_evaluated: false`, and the flag ID, version, request ID and evaluation time when the response has them. It is sent once per distinct ID, groups, flag, rule and variant, whatever value or request it came from. `$feature_flag_called` for a config version 2 flag also gets `$feature_flag_config_version` and `$feature_flag_reason_code`. A targeting match, rollout miss, paused experiment or holdout adds the `$feature_flag_rule_id` and `$feature_flag_rule_type` it names, and a complete experiment split adds these and `$feature_flag_variant`. Other outcomes, including failed flags, add no rule properties. Minimal `$feature_flag_called` events keep these properties, and their dedupe includes them, so equal-valued arms and successive experiments of one flag are each reported. A split that lacks its rule type, rule ID or variant key sends no exposure and reports no rule or variant. `send_feature_flag_events=False` suppresses both events. Config version 1 flags, locally evaluated flags and `$feature_flag_response` are unchanged. +Send `$experiment_exposure` when a remotely evaluated config version 2 flag assigns an experiment variant. The SDK sends it together with `$feature_flag_called`, from `get_feature_flag()`, `feature_enabled()`, `get_feature_flag_result()`, and the `is_enabled()` and `get_flag()` reads of an `evaluate_flags()` snapshot. It carries the flag key, the same `$feature_flag_response` as its `$feature_flag_called`, the rule ID, rule type and variant key, the reason code, `$experiment_exposure_source: "sdk"` and `locally_evaluated: false`, and the flag ID, version, request ID and evaluation time when the response has them. It is sent once per distinct ID, groups, flag, rule and variant, whatever value or request it came from. `$feature_flag_called` for a config version 2 flag also gets `$feature_flag_config_version` and `$feature_flag_reason_code`. A targeting match, rollout miss, paused experiment or holdout adds the `$feature_flag_rule_id` and `$feature_flag_rule_type` it names, and a complete experiment split adds these and `$feature_flag_variant`. Other outcomes, including failed flags, add no rule properties. Minimal `$feature_flag_called` events keep these properties, and their dedupe includes them, so equal-valued arms and successive experiments of one flag are each reported. A split that lacks its rule type, rule ID or variant key sends no exposure and reports no rule or variant. A `get_feature_flag_payload()` lookup with `match_value` reports the caller's value, so it sends no exposure. `send_feature_flag_events=False` suppresses both events. Config version 1 flags, locally evaluated flags and `$feature_flag_response` are unchanged. diff --git a/posthog/async_client.py b/posthog/async_client.py index 3acba87c0..d2c6baf8b 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -1059,7 +1059,7 @@ def _capture_feature_flag_called_if_needed( has_experiment: Optional[bool] = None, minimal_flag_called_events: bool = False, details: Optional[FeatureFlag] = None, - value_from_details: bool = True, + value_from_details: bool = False, ) -> None: capture_groups = { str(group_type): _stringify_id(group_key) diff --git a/posthog/client.py b/posthog/client.py index 4a205722d..169ad5edf 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -4287,6 +4287,9 @@ def _get_feature_flag_result( flag_result = None flag_details = None + # Whether flag_details supplied the value this read reports. Only then may the + # read send $experiment_exposure. + value_from_details = False request_id = None evaluated_at = None feature_flag_error: Optional[str] = None @@ -4386,6 +4389,11 @@ def _get_feature_flag_result( self.log.debug( f"Successfully computed flag remotely: #{key} -> #{flag_result}" ) + # A payload lookup for a caller's match_value reports that value, not + # the record's. A failure above falls back to a stale cached value. + value_from_details = ( + flag_details is not None and override_match_value is None + ) except QuotaLimitError as e: self.log.warning(f"[FEATURE FLAGS] Quota limit exceeded: {e}") feature_flag_error = FeatureFlagError.QUOTA_LIMITED @@ -4436,6 +4444,7 @@ def _get_feature_flag_result( request_id, evaluated_at, flag_details, + value_from_details, feature_flag_error, has_experiment, minimal_flag_called_events, @@ -4727,6 +4736,7 @@ def _capture_feature_flag_called( request_id: Optional[str], evaluated_at: Optional[int], flag_details: Optional[FeatureFlag], + value_from_details: bool, feature_flag_error: Optional[str] = None, has_experiment: Optional[bool] = None, minimal_flag_called_events: bool = False, @@ -4767,10 +4777,7 @@ def _capture_feature_flag_called( has_experiment=has_experiment, minimal_flag_called_events=minimal_flag_called_events, details=flag_details, - # A payload lookup for a caller's ``match_value``, or a stale cached value, - # reports a value this record did not supply, so it sends no exposure. - value_from_details=isinstance(flag_details, FeatureFlag) - and response == flag_details.get_value(), + value_from_details=value_from_details, ) def _capture_feature_flag_called_if_needed( @@ -4785,7 +4792,7 @@ def _capture_feature_flag_called_if_needed( has_experiment: Optional[bool] = None, minimal_flag_called_events: bool = False, details: Optional[FeatureFlag] = None, - value_from_details: bool = True, + value_from_details: bool = False, ) -> None: """Send the flag events of a flag read: ``$feature_flag_called`` and, for an experiment split, ``$experiment_exposure``. See ``_capture_flag_events``. @@ -4793,7 +4800,8 @@ def _capture_feature_flag_called_if_needed( Shared by the single-flag evaluation path and the reads of a ``FeatureFlagEvaluations`` snapshot, so every read reports and dedupes identically. ``details`` is the parsed ``/flags`` record of the read, or - ``None`` for a locally evaluated flag. + ``None`` for a locally evaluated flag. ``value_from_details`` says whether that + record supplied the reported value; only then can the read send an exposure. """ _capture_flag_events( self.capture, diff --git a/posthog/feature_flag_evaluations.py b/posthog/feature_flag_evaluations.py index fe8187d16..1723b85f2 100644 --- a/posthog/feature_flag_evaluations.py +++ b/posthog/feature_flag_evaluations.py @@ -313,4 +313,5 @@ def _record_access(self, key: str) -> None: # The record that supplied the value. A remotely evaluated experiment # split also sends $experiment_exposure, whatever this read returned. details=flag.details if flag else None, + value_from_details=True, ) diff --git a/posthog/test/test_experiment_exposure.py b/posthog/test/test_experiment_exposure.py index 1ab57f9f9..455de534e 100644 --- a/posthog/test/test_experiment_exposure.py +++ b/posthog/test/test_experiment_exposure.py @@ -23,6 +23,7 @@ from posthog.client import _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES, Client from posthog.test.test_flags_v4_response import FIXTURES, READERS, _build from posthog.test.test_utils import FAKE_TEST_API_KEY +from posthog.types import FeatureFlagResult FLAG = "pricing-page" DISTINCT_ID = "visitor-42" @@ -748,19 +749,61 @@ def test_a_payload_read_that_sends_flag_events_also_sends_the_exposure(client): assert [event.name for event in events] == [FLAG_CALLED, EXPOSURE] -def test_a_payload_lookup_for_another_value_sends_no_exposure(client): - # The event reports the caller's match_value, which the record did not supply. +MATCH_VALUE_CASES = [ + ("another value", _split(value="annual"), "monthly"), + ("the record's own value", _split(value="annual"), "annual"), + # Object and number arms both read as True, so equal values prove nothing about + # where a value came from. + ("an object arm", _split(value={"layout": "grid"}), True), + ("a number arm", _split(value=12.5), True), +] + + +@pytest.mark.parametrize("later_read", list(SYNC_READS)) +@pytest.mark.parametrize( + "record, match_value", + [case[1:] for case in MATCH_VALUE_CASES], + ids=[case[0] for case in MATCH_VALUE_CASES], +) +def test_a_payload_lookup_for_a_match_value_sends_no_exposure( + client, record, match_value, later_read +): + # The lookup reports the caller's match_value, not the record's value, so it + # sends no exposure, whatever the value. A later read of the record does. recording, events = _record_events(client) - with _serving(_response(_split())), recording, warnings.catch_warnings(): + with _serving(_response(record)), recording, warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) client.get_feature_flag_payload( - FLAG, DISTINCT_ID, match_value="monthly", send_feature_flag_events=True + FLAG, DISTINCT_ID, match_value=match_value, send_feature_flag_events=True ) + [called] = events + assert called.name == FLAG_CALLED + assert called.properties["$feature_flag_response"] == match_value + # The call still carries the record's attribution. + assert _attribution(called.properties) == _attribution(EXPECTED_EXPOSURE) - [called] = events - assert called.name == FLAG_CALLED - assert called.properties["$feature_flag_response"] == "monthly" - assert called.properties["$feature_flag_config_version"] == 2 + _read(client, later_read) + + [exposure] = _named(events, EXPOSURE) + assert exposure.properties["$feature_flag_variant"] == "annual-first" + + +def test_a_stale_cached_value_sends_no_exposure(client): + # The record arrived, but caching it failed, so the read reports the stale cached + # value instead. That value did not come from the record, even though it is equal. + cache = mock.Mock() + cache._set_cached_remote_flag.side_effect = RuntimeError("cache unavailable") + cache.get_stale_cached_flag.return_value = FeatureFlagResult( + key=FLAG, enabled=True, variant="annual", payload=None, reason=None + ) + client.flag_cache = cache + recording, events = _record_events(client) + with _serving(_response(_split())), recording: + result = client.get_feature_flag_result(FLAG, DISTINCT_ID) + + assert result is not None and result.get_value() == "annual" + cache.get_stale_cached_flag.assert_called_once() + assert [event.name for event in events] == [FLAG_CALLED] @pytest.mark.parametrize("path", list(SYNC_READS)) From c36bcd9df45d23370d36299323dd2cf102d6e823 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Fri, 30 Oct 2026 19:28:40 +0100 Subject: [PATCH 20/34] fix(flags): trim the $experiment_exposure property set $experiment_exposure no longer carries $feature_flag_config_version, $feature_flag_rule_type, $feature_flag_reason_code or $experiment_exposure_source. The exposure of a complete config version 2 experiment split identifies the assignment by flag, variant key and rule ID; the config version, rule type and reason code stay on the $feature_flag_called of the same read. The trigger, both dedupe keys and $feature_flag_response are unchanged. The vendored exposure schema still requires the removed properties, so the tests check exposures against it without them, and check that they are absent. --- .sampo/changesets/experiment-exposure.md | 2 +- posthog/client.py | 4 -- posthog/test/test_experiment_exposure.py | 52 ++++++++++++++++-------- 3 files changed, 37 insertions(+), 21 deletions(-) diff --git a/.sampo/changesets/experiment-exposure.md b/.sampo/changesets/experiment-exposure.md index 38142a52a..af8e9e741 100644 --- a/.sampo/changesets/experiment-exposure.md +++ b/.sampo/changesets/experiment-exposure.md @@ -2,4 +2,4 @@ pypi/posthog: minor --- -Send `$experiment_exposure` when a remotely evaluated config version 2 flag assigns an experiment variant. The SDK sends it together with `$feature_flag_called`, from `get_feature_flag()`, `feature_enabled()`, `get_feature_flag_result()`, and the `is_enabled()` and `get_flag()` reads of an `evaluate_flags()` snapshot. It carries the flag key, the same `$feature_flag_response` as its `$feature_flag_called`, the rule ID, rule type and variant key, the reason code, `$experiment_exposure_source: "sdk"` and `locally_evaluated: false`, and the flag ID, version, request ID and evaluation time when the response has them. It is sent once per distinct ID, groups, flag, rule and variant, whatever value or request it came from. `$feature_flag_called` for a config version 2 flag also gets `$feature_flag_config_version` and `$feature_flag_reason_code`. A targeting match, rollout miss, paused experiment or holdout adds the `$feature_flag_rule_id` and `$feature_flag_rule_type` it names, and a complete experiment split adds these and `$feature_flag_variant`. Other outcomes, including failed flags, add no rule properties. Minimal `$feature_flag_called` events keep these properties, and their dedupe includes them, so equal-valued arms and successive experiments of one flag are each reported. A split that lacks its rule type, rule ID or variant key sends no exposure and reports no rule or variant. A `get_feature_flag_payload()` lookup with `match_value` reports the caller's value, so it sends no exposure. `send_feature_flag_events=False` suppresses both events. Config version 1 flags, locally evaluated flags and `$feature_flag_response` are unchanged. +Send `$experiment_exposure` when a remotely evaluated config version 2 flag assigns an experiment variant. The SDK sends it together with `$feature_flag_called`, from `get_feature_flag()`, `feature_enabled()`, `get_feature_flag_result()`, and the `is_enabled()` and `get_flag()` reads of an `evaluate_flags()` snapshot. It carries the flag key, the same `$feature_flag_response` as its `$feature_flag_called`, the variant key and rule ID, `locally_evaluated: false`, and the flag ID, version, request ID and evaluation time when the response has them. It is sent once per distinct ID, groups, flag, rule and variant, whatever value or request it came from. `$feature_flag_called` for a config version 2 flag also gets `$feature_flag_config_version` and `$feature_flag_reason_code`. A targeting match, rollout miss, paused experiment or holdout adds the `$feature_flag_rule_id` and `$feature_flag_rule_type` it names, and a complete experiment split adds these and `$feature_flag_variant`. Other outcomes, including failed flags, add no rule properties. Minimal `$feature_flag_called` events keep these properties, and their dedupe includes them, so equal-valued arms and successive experiments of one flag are each reported. A split that lacks its rule type, rule ID or variant key sends no exposure and reports no rule or variant. A `get_feature_flag_payload()` lookup with `match_value` reports the caller's value, so it sends no exposure. `send_feature_flag_events=False` suppresses both events. Config version 1 flags, locally evaluated flags and `$feature_flag_response` are unchanged. diff --git a/posthog/client.py b/posthog/client.py index 169ad5edf..aff68f70b 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -592,12 +592,8 @@ def _capture_flag_events( { "$feature_flag": key, "$feature_flag_response": response, - "$feature_flag_config_version": 2, "$feature_flag_variant": attribution.variant_key, "$feature_flag_rule_id": attribution.rule_id, - "$feature_flag_rule_type": attribution.rule_type, - "$feature_flag_reason_code": attribution.reason_code, - "$experiment_exposure_source": "sdk", "locally_evaluated": properties.get("locally_evaluated") is True, } ) diff --git a/posthog/test/test_experiment_exposure.py b/posthog/test/test_experiment_exposure.py index 455de534e..2a2fe678b 100644 --- a/posthog/test/test_experiment_exposure.py +++ b/posthog/test/test_experiment_exposure.py @@ -4,7 +4,9 @@ ``$experiment_exposure`` next to its ``$feature_flag_called``, and every config version 2 record adds its rule attribution to ``$feature_flag_called``. The reader fixtures and event schemas are copied unchanged from a released ``PostHog/posthog-sdk-test-harness`` -contract; ``fixtures/feature_flag_rules_v2/SOURCE.json`` records which. +contract; ``fixtures/feature_flag_rules_v2/SOURCE.json`` records which. The SDK sends a +smaller ``$experiment_exposure`` than that contract's schema requires, so exposures are +checked against the schema without the properties the SDK leaves out. """ from __future__ import annotations @@ -13,7 +15,7 @@ import json import warnings from dataclasses import dataclass -from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional +from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional, Tuple from unittest import mock import jsonschema @@ -44,13 +46,30 @@ ) -def _schema(name: str) -> jsonschema.Draft202012Validator: +# $experiment_exposure leaves these out. The config version, rule type and reason code +# stay on the $feature_flag_called of the same read. +EXPOSURE_LEAVES_OUT = ( + "$feature_flag_config_version", + "$feature_flag_rule_type", + "$feature_flag_reason_code", + "$experiment_exposure_source", +) + + +def _schema( + name: str, leave_out: Tuple[str, ...] = () +) -> jsonschema.Draft202012Validator: schema = json.loads((FIXTURES / name).read_text(encoding="utf-8")) + for property_name in leave_out: + del schema["properties"][property_name] + schema["required"].remove(property_name) return jsonschema.Draft202012Validator(schema) FLAG_CALLED_SCHEMA = _schema("feature_flag_called_context.schema.json") -EXPOSURE_SCHEMA = _schema("experiment_exposure_properties.schema.json") +EXPOSURE_SCHEMA = _schema( + "experiment_exposure_properties.schema.json", leave_out=EXPOSURE_LEAVES_OUT +) def _projection( @@ -309,6 +328,7 @@ def _check_reader_events(case: Dict[str, Any], events: List[_Event]) -> None: ) for exposure in exposures: EXPOSURE_SCHEMA.validate(_projection(EXPOSURE_SCHEMA, exposure.properties)) + assert not set(EXPOSURE_LEAVES_OUT) & set(exposure.properties), key assert ( exposure.properties["$feature_flag_response"] == (called.properties["$feature_flag_response"]) @@ -610,15 +630,19 @@ def test_an_older_server_record_reports_no_attribution(): # --- The exact events of a split --------------------------------------------------------- +# The attribution of a split's $feature_flag_called. +EXPECTED_ATTRIBUTION = { + "$feature_flag_config_version": 2, + "$feature_flag_reason_code": "experiment_split", + "$feature_flag_rule_id": RULE, + "$feature_flag_rule_type": "experiment", + "$feature_flag_variant": "annual-first", +} EXPECTED_EXPOSURE = { "$feature_flag": FLAG, "$feature_flag_response": "annual", - "$feature_flag_config_version": 2, "$feature_flag_variant": "annual-first", "$feature_flag_rule_id": RULE, - "$feature_flag_rule_type": "experiment", - "$feature_flag_reason_code": "experiment_split", - "$experiment_exposure_source": "sdk", "locally_evaluated": False, "$feature_flag_id": 77, "$feature_flag_version": 3, @@ -646,11 +670,7 @@ def test_a_split_sends_both_events_with_exact_properties(path): "$feature_flag_version": 3, "$feature_flag_id": 77, "$feature_flag_has_experiment": True, - "$feature_flag_config_version": 2, - "$feature_flag_reason_code": "experiment_split", - "$feature_flag_rule_id": RULE, - "$feature_flag_rule_type": "experiment", - "$feature_flag_variant": "annual-first", + **EXPECTED_ATTRIBUTION, } assert exposure.properties == EXPECTED_EXPOSURE assert exposure.distinct_id == called.distinct_id == DISTINCT_ID @@ -670,7 +690,7 @@ async def read(client: Any) -> None: assert [event.name for event in events] == [FLAG_CALLED, EXPOSURE] called, exposure = events assert exposure.properties == EXPECTED_EXPOSURE - assert _attribution(called.properties) == _attribution(EXPECTED_EXPOSURE) + assert _attribution(called.properties) == EXPECTED_ATTRIBUTION # The async client sends group keys as strings. assert exposure.groups == called.groups == {"company": "7"} assert exposure.distinct_id == called.distinct_id == DISTINCT_ID @@ -780,7 +800,7 @@ def test_a_payload_lookup_for_a_match_value_sends_no_exposure( assert called.name == FLAG_CALLED assert called.properties["$feature_flag_response"] == match_value # The call still carries the record's attribution. - assert _attribution(called.properties) == _attribution(EXPECTED_EXPOSURE) + assert _attribution(called.properties) == EXPECTED_ATTRIBUTION _read(client, later_read) @@ -1105,7 +1125,7 @@ def before_send(message: Dict[str, Any]) -> Dict[str, Any]: called, exposure = messages assert set(called["properties"]) <= _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES assert "plan" not in called["properties"] - assert _attribution(called["properties"]) == _attribution(EXPECTED_EXPOSURE) + assert _attribution(called["properties"]) == EXPECTED_ATTRIBUTION # The exposure is a full event. assert exposure["event"] == EXPOSURE assert exposure["properties"]["plan"] == "team" From 119a4c69fac6631bb0f07d9fcf7e272e01bb728a Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Fri, 30 Oct 2026 19:28:55 +0100 Subject: [PATCH 21/34] fix(flags): report a config version 2 reason by code only $feature_flag_called for a config version 2 flag carried both $feature_flag_reason_code and the human-readable $feature_flag_reason. The reason code now replaces the description on these events, full and minimal. Events of config version 1 flags, flags from servers without typed values and locally evaluated flags keep $feature_flag_reason as before. --- .sampo/changesets/experiment-exposure.md | 2 +- posthog/client.py | 4 +- posthog/test/test_experiment_exposure.py | 98 +++++++++++++++++++++++- posthog/test/test_flags_v4_response.py | 12 +-- 4 files changed, 107 insertions(+), 9 deletions(-) diff --git a/.sampo/changesets/experiment-exposure.md b/.sampo/changesets/experiment-exposure.md index af8e9e741..5636ed78c 100644 --- a/.sampo/changesets/experiment-exposure.md +++ b/.sampo/changesets/experiment-exposure.md @@ -2,4 +2,4 @@ pypi/posthog: minor --- -Send `$experiment_exposure` when a remotely evaluated config version 2 flag assigns an experiment variant. The SDK sends it together with `$feature_flag_called`, from `get_feature_flag()`, `feature_enabled()`, `get_feature_flag_result()`, and the `is_enabled()` and `get_flag()` reads of an `evaluate_flags()` snapshot. It carries the flag key, the same `$feature_flag_response` as its `$feature_flag_called`, the variant key and rule ID, `locally_evaluated: false`, and the flag ID, version, request ID and evaluation time when the response has them. It is sent once per distinct ID, groups, flag, rule and variant, whatever value or request it came from. `$feature_flag_called` for a config version 2 flag also gets `$feature_flag_config_version` and `$feature_flag_reason_code`. A targeting match, rollout miss, paused experiment or holdout adds the `$feature_flag_rule_id` and `$feature_flag_rule_type` it names, and a complete experiment split adds these and `$feature_flag_variant`. Other outcomes, including failed flags, add no rule properties. Minimal `$feature_flag_called` events keep these properties, and their dedupe includes them, so equal-valued arms and successive experiments of one flag are each reported. A split that lacks its rule type, rule ID or variant key sends no exposure and reports no rule or variant. A `get_feature_flag_payload()` lookup with `match_value` reports the caller's value, so it sends no exposure. `send_feature_flag_events=False` suppresses both events. Config version 1 flags, locally evaluated flags and `$feature_flag_response` are unchanged. +Send `$experiment_exposure` when a remotely evaluated config version 2 flag assigns an experiment variant. The SDK sends it together with `$feature_flag_called`, from `get_feature_flag()`, `feature_enabled()`, `get_feature_flag_result()`, and the `is_enabled()` and `get_flag()` reads of an `evaluate_flags()` snapshot. It carries the flag key, the same `$feature_flag_response` as its `$feature_flag_called`, the variant key and rule ID, `locally_evaluated: false`, and the flag ID, version, request ID and evaluation time when the response has them. It is sent once per distinct ID, groups, flag, rule and variant, whatever value or request it came from. `$feature_flag_called` for a config version 2 flag gets `$feature_flag_config_version` and `$feature_flag_reason_code`, which replaces the human-readable `$feature_flag_reason`. A targeting match, rollout miss, paused experiment or holdout adds the `$feature_flag_rule_id` and `$feature_flag_rule_type` it names, and a complete experiment split adds these and `$feature_flag_variant`. Other outcomes, including failed flags, add no rule properties. Minimal `$feature_flag_called` events keep these properties, and their dedupe includes them, so equal-valued arms and successive experiments of one flag are each reported. A split that lacks its rule type, rule ID or variant key sends no exposure and reports no rule or variant. A `get_feature_flag_payload()` lookup with `match_value` reports the caller's value, so it sends no exposure. `send_feature_flag_events=False` suppresses both events. Events of config version 1 flags, flags from servers without typed values and locally evaluated flags are unchanged, including their `$feature_flag_reason`, and `$feature_flag_response` is unchanged on every event. diff --git a/posthog/client.py b/posthog/client.py index aff68f70b..d31cea2cb 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -506,7 +506,8 @@ def _capture_flag_events( that group-scoped flags fire a separate event for each group a user is evaluated under. A config version 2 record adds its attribution to the event and to that tuple, so equal-valued A/A arms, successive experiments of one flag, and - holdouts are each reported. A config version 1 tuple is unchanged. + holdouts are each reported. Its reason code replaces the human-readable + ``$feature_flag_reason``. A config version 1 event and tuple are unchanged. ``has_experiment`` is the server-reported signal for whether the flag is linked to an experiment (``None`` when the server did not report it). When the @@ -541,6 +542,7 @@ def _capture_flag_events( if feature_flag_reported_key not in reported_flags: if attribution is not None: + properties.pop("$feature_flag_reason", None) properties.update(attribution.flag_called_properties()) # Record the server's experiment signal when known, so minimization's impact # can be measured by segmenting on it. diff --git a/posthog/test/test_experiment_exposure.py b/posthog/test/test_experiment_exposure.py index 2a2fe678b..301a53222 100644 --- a/posthog/test/test_experiment_exposure.py +++ b/posthog/test/test_experiment_exposure.py @@ -627,6 +627,98 @@ def test_an_older_server_record_reports_no_attribution(): assert _named(events, EXPOSURE) == [] +# --- $feature_flag_reason ------------------------------------------------------------------ + +REASON_DESCRIPTION = "Matched rule 1" + + +@pytest.mark.parametrize("path", list(SYNC_READS)) +@pytest.mark.parametrize( + "record", + [ + _record( + "targeting_match", + rule_type="targeted_release", + rule_id=RULE, + description=REASON_DESCRIPTION, + ), + _record("no_rule_match", False, description=REASON_DESCRIPTION), + _record("error", None, failed=True, description=REASON_DESCRIPTION), + _split(description=REASON_DESCRIPTION), + ], + ids=["targeting_match", "no_rule_match", "failed", "experiment_split"], +) +def test_a_config_version_2_call_reports_its_reason_by_code_only(record, path): + events = _read_events(path, _response(record)) + + [called] = _named(events, FLAG_CALLED) + assert called.properties["$feature_flag_reason_code"] == record["reason"]["code"] + assert "$feature_flag_reason" not in called.properties + + +@pytest.mark.parametrize("path", list(SYNC_READS)) +@pytest.mark.parametrize( + "record", + [ + _record( + "condition_match", + "annual", + config_version=1, + description=REASON_DESCRIPTION, + ), + # A record from a server before /flags?v=4, which has no config version. + { + "key": FLAG, + "enabled": True, + "variant": "annual", + "reason": { + "code": "condition_match", + "condition_index": 0, + "description": REASON_DESCRIPTION, + }, + "metadata": {"id": 77, "version": 3}, + }, + ], + ids=["config_version_1", "older_server"], +) +def test_a_call_without_config_version_2_keeps_its_reason_description(record, path): + events = _read_events(path, _response(record)) + + [called] = _named(events, FLAG_CALLED) + assert called.properties["$feature_flag_reason"] == REASON_DESCRIPTION + assert _attribution(called.properties) == {} + + +@pytest.mark.parametrize( + "config_version, code, reason", + [(1, "condition_match", REASON_DESCRIPTION), (2, "targeting_match", None)], +) +def test_minimal_calls_keep_the_reason_description_only_without_config_version_2( + config_version, code, reason +): + messages: List[Dict[str, Any]] = [] + + def before_send(message: Dict[str, Any]) -> Dict[str, Any]: + messages.append(message) + return message + + client = Client(FAKE_TEST_API_KEY, send=False, before_send=before_send) + record = _record( + code, config_version=config_version, description=REASON_DESCRIPTION + ) + try: + with _serving(_response(record, minimalFlagCalledEvents=True)): + client.evaluate_flags(DISTINCT_ID).get_flag(FLAG) + client.get_feature_flag_result(FLAG, "visitor-43") + finally: + client.shutdown() + + assert [message["event"] for message in messages] == [FLAG_CALLED, FLAG_CALLED] + for message in messages: + assert set(message["properties"]) <= _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES + assert message["properties"].get("$feature_flag_reason") == reason + + # --- The exact events of a split --------------------------------------------------------- @@ -666,7 +758,7 @@ def test_a_split_sends_both_events_with_exact_properties(path): f"$feature/{FLAG}": "annual", "$feature_flag_request_id": REQUEST_ID, "$feature_flag_evaluated_at": EVALUATED_AT, - "$feature_flag_reason": "Assigned variant 'annual-first'", + # The reason code replaces the record's reason description. "$feature_flag_version": 3, "$feature_flag_id": 77, "$feature_flag_has_experiment": True, @@ -685,12 +777,14 @@ async def read(client: Any) -> None: ) snapshot.get_flag(FLAG) - events = await _read_async_events([_response(_split())], [read]) + response = _response(_split(description="Assigned variant 'annual-first'")) + events = await _read_async_events([response], [read]) assert [event.name for event in events] == [FLAG_CALLED, EXPOSURE] called, exposure = events assert exposure.properties == EXPECTED_EXPOSURE assert _attribution(called.properties) == EXPECTED_ATTRIBUTION + assert "$feature_flag_reason" not in called.properties # The async client sends group keys as strings. assert exposure.groups == called.groups == {"company": "7"} assert exposure.distinct_id == called.distinct_id == DISTINCT_ID diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py index 097a019e9..6056c0220 100644 --- a/posthog/test/test_flags_v4_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -875,8 +875,9 @@ def _parity_response(shape: str) -> Dict[str, Any]: } -# A config version 2 record adds these to its flag events. A v2-shaped response has no -# config version 2 records, so they are left out of the comparison and checked in +# A config version 2 record adds these to its flag events, and leaves out +# $feature_flag_reason. A v2-shaped response has no config version 2 records, so they +# are left out of the comparison of the v2-* flags and checked in # test_experiment_exposure.py. _CONFIG_VERSION_2_EVENT_PROPERTIES = { "$feature_flag_config_version", @@ -888,12 +889,13 @@ def _parity_response(shape: str) -> Dict[str, Any]: def _flag_event_properties(message: Dict[str, Any]) -> Dict[str, Any]: + left_out = set(_CONFIG_VERSION_2_EVENT_PROPERTIES) + if str(message["properties"].get("$feature_flag", "")).startswith("v2-"): + left_out.add("$feature_flag_reason") return { key: value for key, value in message["properties"].items() - if ( - key.startswith("$feature") and key not in _CONFIG_VERSION_2_EVENT_PROPERTIES - ) + if (key.startswith("$feature") and key not in left_out) or key in ("$active_feature_flags", "locally_evaluated") } From fd6156c32815a6d39c0804921e082a95a9773c92 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Fri, 30 Oct 2026 20:55:51 +0100 Subject: [PATCH 22/34] test(flags): vendor the harness 1.27.0 event schemas Contract 8.0.0 trims the experiment exposure schema to the properties the SDK sends and rejects $feature_flag_reason on config version 2 calls, so the exposure validator no longer drops properties from the vendored schema. The four other vendored files are unchanged. --- .../feature_flag_rules_v2/SOURCE.json | 10 +++++----- ...experiment_exposure_properties.schema.json | 20 ++----------------- .../feature_flag_called_context.schema.json | 16 ++++++++++++--- posthog/test/test_experiment_exposure.py | 20 ++++++------------- posthog/test/test_flags_v4_response.py | 4 ++-- 5 files changed, 28 insertions(+), 42 deletions(-) diff --git a/posthog/test/fixtures/feature_flag_rules_v2/SOURCE.json b/posthog/test/fixtures/feature_flag_rules_v2/SOURCE.json index a9e158b1c..f7b015d0a 100644 --- a/posthog/test/fixtures/feature_flag_rules_v2/SOURCE.json +++ b/posthog/test/fixtures/feature_flag_rules_v2/SOURCE.json @@ -1,10 +1,10 @@ { "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.22.0", - "revision": "17d861f8e230fe943934d70e3eebf5cff6bf3aa4", + "release": "1.27.0", + "revision": "75fb00092c6045daef60c30acc10ac83afefbfe9", "contract_directory": "contracts/feature_flag_rules_v2", - "contract_version": "7.0.0", + "contract_version": "8.0.0", "files": { "readers.json": { "source_path": "fixtures/wire/readers.json", @@ -24,11 +24,11 @@ }, "feature_flag_called_context.schema.json": { "source_path": "schemas/feature_flag_called_context.schema.json", - "sha256": "56ca931f58ce41f22f5bdf63da99f1ad543e78d32de08f1762c3c10b07fcabc5" + "sha256": "816acfe33391d38471a4530ddac8b147c2bdc2e3c4ddbd8f384368faf57ee3b6" }, "experiment_exposure_properties.schema.json": { "source_path": "schemas/experiment_exposure_properties.schema.json", - "sha256": "0cd39571ef19749ae380a1e88384f97916e5d0ba126f9e461026785a6d3f0aad" + "sha256": "6d33707de086537abb90bb6b649da49bd4aca359fcf2c7877eceffb0a86dee63" } } } diff --git a/posthog/test/fixtures/feature_flag_rules_v2/experiment_exposure_properties.schema.json b/posthog/test/fixtures/feature_flag_rules_v2/experiment_exposure_properties.schema.json index 5ec195eab..9f50ebe79 100644 --- a/posthog/test/fixtures/feature_flag_rules_v2/experiment_exposure_properties.schema.json +++ b/posthog/test/fixtures/feature_flag_rules_v2/experiment_exposure_properties.schema.json @@ -1,18 +1,14 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://posthog.com/contracts/feature_flag_rules_v2/experiment_exposure_properties.schema.json", - "title": "SDK-origin $experiment_exposure required properties", + "title": "SDK-sent $experiment_exposure properties", "type": "object", "additionalProperties": false, "required": [ "$feature_flag", "$feature_flag_response", - "$feature_flag_config_version", "$feature_flag_variant", "$feature_flag_rule_id", - "$feature_flag_rule_type", - "$feature_flag_reason_code", - "$experiment_exposure_source", "locally_evaluated" ], "properties": { @@ -31,9 +27,6 @@ } ] }, - "$feature_flag_config_version": { - "const": 2 - }, "$feature_flag_variant": { "type": "string" }, @@ -41,15 +34,6 @@ "type": "string", "format": "uuid" }, - "$feature_flag_rule_type": { - "const": "experiment" - }, - "$feature_flag_reason_code": { - "const": "experiment_split" - }, - "$experiment_exposure_source": { - "const": "sdk" - }, "locally_evaluated": { "type": "boolean" }, @@ -57,7 +41,7 @@ "const": true } }, - "description": "Closed projection of required SDK-origin exposure properties; forced-variant context is forbidden. Capture identity and existing optional transport properties are outside this projection. Scan the entire event recursively for assignment seeds.", + "description": "Closed projection of the properties of an SDK-sent exposure. The SDK sends it only for a config version 2 experiment split, so it carries no config version, rule type, reason code or source marker; the rule ID marks it as SDK-sent. Experiment ID and forced-variant context are forbidden. Capture identity and existing optional transport properties are outside this projection. Scan the entire event recursively for assignment seeds.", "not": { "required": [ "$feature_flag_forced_variant" diff --git a/posthog/test/fixtures/feature_flag_rules_v2/feature_flag_called_context.schema.json b/posthog/test/fixtures/feature_flag_rules_v2/feature_flag_called_context.schema.json index 1b2924e88..e55fd2fa2 100644 --- a/posthog/test/fixtures/feature_flag_rules_v2/feature_flag_called_context.schema.json +++ b/posthog/test/fixtures/feature_flag_rules_v2/feature_flag_called_context.schema.json @@ -51,11 +51,12 @@ "type": "string", "minLength": 1 }, + "$feature_flag_reason": {}, "$feature_flag_forced_variant": { "const": true } }, - "description": "Closed projection of the flag-access context properties. Existing capture fields are outside this projection. Complete v2 producer context follows the terminal outcome; tolerant-reader diagnostics may omit an incomplete attribution tuple.", + "description": "Closed projection of the flag-access context properties. Other existing capture fields are outside this projection. Complete v2 producer context follows the terminal outcome; tolerant-reader diagnostics may omit an incomplete attribution tuple. A config version 2 call carries $feature_flag_reason_code and never $feature_flag_reason, the reason description that only version 1 calls carry.", "allOf": [ { "if": { @@ -73,8 +74,17 @@ "$feature_flag_reason_code" ], "not": { - "required": [ - "$feature_flag_forced_variant" + "anyOf": [ + { + "required": [ + "$feature_flag_forced_variant" + ] + }, + { + "required": [ + "$feature_flag_reason" + ] + } ] }, "oneOf": [ diff --git a/posthog/test/test_experiment_exposure.py b/posthog/test/test_experiment_exposure.py index 301a53222..ba0dc3f8a 100644 --- a/posthog/test/test_experiment_exposure.py +++ b/posthog/test/test_experiment_exposure.py @@ -4,9 +4,7 @@ ``$experiment_exposure`` next to its ``$feature_flag_called``, and every config version 2 record adds its rule attribution to ``$feature_flag_called``. The reader fixtures and event schemas are copied unchanged from a released ``PostHog/posthog-sdk-test-harness`` -contract; ``fixtures/feature_flag_rules_v2/SOURCE.json`` records which. The SDK sends a -smaller ``$experiment_exposure`` than that contract's schema requires, so exposures are -checked against the schema without the properties the SDK leaves out. +contract; ``fixtures/feature_flag_rules_v2/SOURCE.json`` records which. """ from __future__ import annotations @@ -15,7 +13,7 @@ import json import warnings from dataclasses import dataclass -from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional, Tuple +from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional from unittest import mock import jsonschema @@ -47,7 +45,8 @@ # $experiment_exposure leaves these out. The config version, rule type and reason code -# stay on the $feature_flag_called of the same read. +# stay on the $feature_flag_called of the same read. The schema projection drops +# undeclared properties, so their absence is asserted separately. EXPOSURE_LEAVES_OUT = ( "$feature_flag_config_version", "$feature_flag_rule_type", @@ -56,20 +55,13 @@ ) -def _schema( - name: str, leave_out: Tuple[str, ...] = () -) -> jsonschema.Draft202012Validator: +def _schema(name: str) -> jsonschema.Draft202012Validator: schema = json.loads((FIXTURES / name).read_text(encoding="utf-8")) - for property_name in leave_out: - del schema["properties"][property_name] - schema["required"].remove(property_name) return jsonschema.Draft202012Validator(schema) FLAG_CALLED_SCHEMA = _schema("feature_flag_called_context.schema.json") -EXPOSURE_SCHEMA = _schema( - "experiment_exposure_properties.schema.json", leave_out=EXPOSURE_LEAVES_OUT -) +EXPOSURE_SCHEMA = _schema("experiment_exposure_properties.schema.json") def _projection( diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py index 6056c0220..fa2285627 100644 --- a/posthog/test/test_flags_v4_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -82,8 +82,8 @@ def _evaluate(client: Client, response: Dict[str, Any]) -> FeatureFlagEvaluation def test_vendored_fixtures_match_the_recorded_release_digests(): source = _load("SOURCE.json") - assert source["release"] == "1.22.0" - assert source["contract_version"] == "7.0.0" + assert source["release"] == "1.27.0" + assert source["contract_version"] == "8.0.0" for name, entry in source["files"].items(): digest = hashlib.sha256((FIXTURES / name).read_bytes()).hexdigest() assert digest == entry["sha256"], name From c5c53f2f1183b4bd00d9031c6e31cb573b7ce594 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Mon, 2 Nov 2026 12:41:53 +0100 Subject: [PATCH 23/34] 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 f2357566e52c9fc52448dd6a975acc4a5a640002 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Mon, 2 Nov 2026 12:49:16 +0100 Subject: [PATCH 24/34] fix(flags): keep a config version 1 minimal event trimmed as before The config version 2 attribution names were added to the shared minimal-event allowlist, so a minimized config version 1 event kept super properties that happened to use those names. Use a wider allowlist for config version 2 events only, and import FeatureFlag privately in the async client. --- posthog/async_client.py | 4 +-- posthog/client.py | 22 ++++++++---- posthog/test/test_experiment_exposure.py | 44 ++++++++++++++++++++++-- 3 files changed, 59 insertions(+), 11 deletions(-) diff --git a/posthog/async_client.py b/posthog/async_client.py index fd549ad7b..3e1d3fbf0 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -78,8 +78,8 @@ ) from .release_id import _resolve_release_id from .request import QuotaLimitError, determine_server_host, normalize_host +from .types import FeatureFlag as _FeatureFlag from .types import ( - FeatureFlag, FlagMetadata, FlagValue, _has_untyped_flag_records, @@ -1109,7 +1109,7 @@ def _capture_feature_flag_called_if_needed( disable_geoip: Optional[bool] = None, has_experiment: Optional[bool] = None, minimal_flag_called_events: bool = False, - details: Optional[FeatureFlag] = None, + details: Optional[_FeatureFlag] = None, value_from_details: bool = False, ) -> None: capture_groups = { diff --git a/posthog/client.py b/posthog/client.py index b9c93e30d..5f7476b5b 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -342,12 +342,6 @@ def wrapper(self, *args, **kwargs): "$feature_flag_evaluated_at", "$feature_flag_error", "locally_evaluated", - # Config version 2 attribution, which minimal events must keep - "$feature_flag_config_version", - "$feature_flag_reason_code", - "$feature_flag_rule_id", - "$feature_flag_rule_type", - "$feature_flag_variant", # Correctness-required "$groups", "$process_person_profile", @@ -368,6 +362,20 @@ def wrapper(self, *args, **kwargs): } ) +# A config version 2 event also keeps its attribution when minimized. Only those +# events use this wider allowlist, so a config version 1 event is trimmed as before, +# even when super properties happen to use these names. +_MINIMAL_V2_FLAG_CALLED_EVENT_PROPERTIES: frozenset[str] = ( + _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES + | { + "$feature_flag_config_version", + "$feature_flag_reason_code", + "$feature_flag_rule_id", + "$feature_flag_rule_type", + "$feature_flag_variant", + } +) + def _parse_has_experiment(value: Any) -> Optional[bool]: """Server-reported experiment linkage; anything but an explicit bool means unknown.""" @@ -560,6 +568,8 @@ def _capture_flag_events( if should_minimize: extra_capture_kwargs["_property_allowlist"] = ( _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES + if attribution is None + else _MINIMAL_V2_FLAG_CALLED_EVENT_PROPERTIES ) capture( diff --git a/posthog/test/test_experiment_exposure.py b/posthog/test/test_experiment_exposure.py index ba0dc3f8a..98b1b4546 100644 --- a/posthog/test/test_experiment_exposure.py +++ b/posthog/test/test_experiment_exposure.py @@ -20,7 +20,11 @@ import pytest from posthog import AsyncPosthog -from posthog.client import _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES, Client +from posthog.client import ( + _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES, + _MINIMAL_V2_FLAG_CALLED_EVENT_PROPERTIES, + Client, +) from posthog.test.test_flags_v4_response import FIXTURES, READERS, _build from posthog.test.test_utils import FAKE_TEST_API_KEY from posthog.types import FeatureFlagResult @@ -705,12 +709,46 @@ def before_send(message: Dict[str, Any]) -> Dict[str, Any]: finally: client.shutdown() + allowlist = ( + _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES + if config_version == 1 + else _MINIMAL_V2_FLAG_CALLED_EVENT_PROPERTIES + ) assert [message["event"] for message in messages] == [FLAG_CALLED, FLAG_CALLED] for message in messages: - assert set(message["properties"]) <= _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES + assert set(message["properties"]) <= allowlist assert message["properties"].get("$feature_flag_reason") == reason +def test_minimal_config_version_1_calls_drop_super_properties_named_like_attribution(): + attribution_names = ( + _MINIMAL_V2_FLAG_CALLED_EVENT_PROPERTIES - _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES + ) + messages: List[Dict[str, Any]] = [] + + def before_send(message: Dict[str, Any]) -> Dict[str, Any]: + messages.append(message) + return message + + client = Client( + FAKE_TEST_API_KEY, + send=False, + before_send=before_send, + super_properties={name: "set-by-the-app" for name in attribution_names}, + ) + record = _record( + "condition_match", config_version=1, description=REASON_DESCRIPTION + ) + try: + with _serving(_response(record, minimalFlagCalledEvents=True)): + client.get_feature_flag_result(FLAG, DISTINCT_ID) + finally: + client.shutdown() + + [message] = messages + assert not attribution_names & set(message["properties"]) + + # --- The exact events of a split --------------------------------------------------------- @@ -1209,7 +1247,7 @@ def before_send(message: Dict[str, Any]) -> Dict[str, Any]: client.shutdown() called, exposure = messages - assert set(called["properties"]) <= _MINIMAL_FLAG_CALLED_EVENT_PROPERTIES + assert set(called["properties"]) <= _MINIMAL_V2_FLAG_CALLED_EVENT_PROPERTIES assert "plan" not in called["properties"] assert _attribution(called["properties"]) == EXPECTED_ATTRIBUTION # The exposure is a full event. From bd9c1ada3b4e620567ea56260fa891d42d0b0e97 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Mon, 2 Nov 2026 19:41:35 +0100 Subject: [PATCH 25/34] 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 26/34] 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 27/34] 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 28/34] 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 29/34] 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 789abf1ada0a0605fd080ce82fa6d73f62e3d0c7 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Tue, 3 Nov 2026 14:04:47 +0100 Subject: [PATCH 30/34] test(flags): expect the config version 2 attribution on released-behaviour events --- posthog/test/test_flags_released_behaviour.py | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 posthog/test/test_flags_released_behaviour.py diff --git a/posthog/test/test_flags_released_behaviour.py b/posthog/test/test_flags_released_behaviour.py new file mode 100644 index 000000000..368f417cd --- /dev/null +++ b/posthog/test/test_flags_released_behaviour.py @@ -0,0 +1,90 @@ +"""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(request, 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 + + if request.node.callspec.params["results"] == "v4": + # A $feature_flag_called for a config version 2 record carries its config + # version and reason code in place of the reason text. Only the v4 shape says + # a record is config version 2; config version 1 events are unchanged. + for name, properties in expected["events"]: + if name == "$feature_flag_called" and properties[ + "$feature_flag" + ].startswith("v2-"): + assert ( + properties.pop("$feature_flag_reason") + == scenarios.MATCHED["description"] + ) + properties["$feature_flag_config_version"] = 2 + properties["$feature_flag_reason_code"] = scenarios.MATCHED["code"] + + 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"] == {} + + 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 + + +@pytest.mark.parametrize("name", sorted(RELEASED["cache histories"])) +def test_fallback_cache_histories_match_7_82_1(results, name): + # 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 34012f140cfdc12874eef4739474da8b01354f92 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Wed, 4 Nov 2026 09:29:42 +0100 Subject: [PATCH 31/34] fix(flags): send the rule ID and rule type as a pair, or neither Follow PostHog/sdk-specs#162. On a config version 2 $feature_flag_called, $feature_flag_rule_id and $feature_flag_rule_type are sent together, and only with a rule type the reason code allows: targeted_release or percentage_rollout for targeting_match, percentage_rollout or experiment for rollout_miss, and experiment for experiment_paused, holdout and experiment_split. A record with no rule ID, no rule type or another rule type, including one this SDK does not know, sends neither. The event keeps the config version and reason code, and the value is unchanged. A targeting match that names an experiment rule gets no attribution and no exposure; the exposure already required a complete experiment split. Contract 8.0.0 rejects these calls without their rule pair, so the new cases are not validated against the vendored schema until a harness release that accepts them is copied. --- .sampo/changesets/experiment-exposure.md | 2 +- posthog/client.py | 43 ++++++++------- posthog/test/test_experiment_exposure.py | 66 ++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 20 deletions(-) diff --git a/.sampo/changesets/experiment-exposure.md b/.sampo/changesets/experiment-exposure.md index 5636ed78c..aa17cf282 100644 --- a/.sampo/changesets/experiment-exposure.md +++ b/.sampo/changesets/experiment-exposure.md @@ -2,4 +2,4 @@ pypi/posthog: minor --- -Send `$experiment_exposure` when a remotely evaluated config version 2 flag assigns an experiment variant. The SDK sends it together with `$feature_flag_called`, from `get_feature_flag()`, `feature_enabled()`, `get_feature_flag_result()`, and the `is_enabled()` and `get_flag()` reads of an `evaluate_flags()` snapshot. It carries the flag key, the same `$feature_flag_response` as its `$feature_flag_called`, the variant key and rule ID, `locally_evaluated: false`, and the flag ID, version, request ID and evaluation time when the response has them. It is sent once per distinct ID, groups, flag, rule and variant, whatever value or request it came from. `$feature_flag_called` for a config version 2 flag gets `$feature_flag_config_version` and `$feature_flag_reason_code`, which replaces the human-readable `$feature_flag_reason`. A targeting match, rollout miss, paused experiment or holdout adds the `$feature_flag_rule_id` and `$feature_flag_rule_type` it names, and a complete experiment split adds these and `$feature_flag_variant`. Other outcomes, including failed flags, add no rule properties. Minimal `$feature_flag_called` events keep these properties, and their dedupe includes them, so equal-valued arms and successive experiments of one flag are each reported. A split that lacks its rule type, rule ID or variant key sends no exposure and reports no rule or variant. A `get_feature_flag_payload()` lookup with `match_value` reports the caller's value, so it sends no exposure. `send_feature_flag_events=False` suppresses both events. Events of config version 1 flags, flags from servers without typed values and locally evaluated flags are unchanged, including their `$feature_flag_reason`, and `$feature_flag_response` is unchanged on every event. +Send `$experiment_exposure` when a remotely evaluated config version 2 flag assigns an experiment variant. The SDK sends it together with `$feature_flag_called`, from `get_feature_flag()`, `feature_enabled()`, `get_feature_flag_result()`, and the `is_enabled()` and `get_flag()` reads of an `evaluate_flags()` snapshot. It carries the flag key, the same `$feature_flag_response` as its `$feature_flag_called`, the variant key and rule ID, `locally_evaluated: false`, and the flag ID, version, request ID and evaluation time when the response has them. It is sent once per distinct ID, groups, flag, rule and variant, whatever value or request it came from. `$feature_flag_called` for a config version 2 flag gets `$feature_flag_config_version` and `$feature_flag_reason_code`, which replaces the human-readable `$feature_flag_reason`. A targeting match, rollout miss, paused experiment or holdout adds the `$feature_flag_rule_id` and `$feature_flag_rule_type` it names, and a complete experiment split adds these and `$feature_flag_variant`. The rule ID and rule type are sent together, and only with a rule type the reason allows: `targeted_release` or `percentage_rollout` for a targeting match, `percentage_rollout` or `experiment` for a rollout miss, and `experiment` for a paused experiment, a holdout or a split. A record with no rule ID, no rule type or another rule type, such as a targeting match that names an experiment rule, sends neither and no exposure. Other outcomes, including failed flags, add no rule properties. Minimal `$feature_flag_called` events keep these properties, and their dedupe includes them, so equal-valued arms and successive experiments of one flag are each reported. A split that lacks its rule type, rule ID or variant key sends no exposure and reports no rule or variant. A `get_feature_flag_payload()` lookup with `match_value` reports the caller's value, so it sends no exposure. `send_feature_flag_events=False` suppresses both events. Events of config version 1 flags, flags from servers without typed values and locally evaluated flags are unchanged, including their `$feature_flag_reason`, and `$feature_flag_response` is unchanged on every event. diff --git a/posthog/client.py b/posthog/client.py index 985139f59..d3b725a22 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -15,6 +15,7 @@ Any, Callable, Dict, + FrozenSet, List, Mapping, NamedTuple, @@ -396,17 +397,15 @@ def _metadata_has_experiment(metadata: Any) -> Optional[bool]: _EXPERIMENT_RULE_TYPE = "experiment" # The reason codes of a config version 2 record that report the rule that produced -# the result. Any other reason code, and any failed record, reports no rule, even if -# the record names one. -_RULE_REASON_CODES = frozenset( - { - "targeting_match", - _EXPERIMENT_SPLIT_REASON, - "rollout_miss", - "experiment_paused", - "holdout", - } -) +# the result, and the rule types each one allows. Any other reason code, and any +# failed record, reports no rule, even if the record names one. +_RULE_TYPES_BY_REASON_CODE: Dict[str, FrozenSet[str]] = { + "targeting_match": frozenset({"targeted_release", "percentage_rollout"}), + _EXPERIMENT_SPLIT_REASON: frozenset({_EXPERIMENT_RULE_TYPE}), + "rollout_miss": frozenset({"percentage_rollout", _EXPERIMENT_RULE_TYPE}), + "experiment_paused": frozenset({_EXPERIMENT_RULE_TYPE}), + "holdout": frozenset({_EXPERIMENT_RULE_TYPE}), +} # ``$experiment_exposure`` copies these from the ``$feature_flag_called`` of the same # read, when it has them. @@ -459,12 +458,15 @@ def _rule_attribution(details: Optional[FeatureFlag]) -> Optional[_RuleAttributi evaluation details are read, never the value a getter returned. The config version and reason code are always reported. The rule ID and rule - type are reported, when the record has them, only for a targeting match, a - rollout miss, a paused experiment, a holdout and a complete experiment split. A - failed record, or any other reason code, reports neither. The variant key is - reported only for a complete experiment split: reason ``experiment_split``, rule - type ``experiment``, a rule ID and a variant key. A split without all of them is - malformed. It keeps its reason but reports no rule ID, rule type or variant, and + type are reported together or not at all, only for a targeting match, a rollout + miss, a paused experiment, a holdout and a complete experiment split, and only + with a rule type the reason code allows (``_RULE_TYPES_BY_REASON_CODE``). A + record with no rule ID, no rule type or another rule type, such as a targeting + match that names an experiment rule, reports neither. So does a failed record or + any other reason code. The variant key is reported only for a complete + experiment split: reason ``experiment_split``, rule type ``experiment``, a rule + ID and a variant key. A split without all of them is malformed. A record that + reports no rule keeps its reason, reports no rule ID, rule type or variant, and sends no exposure. Its identity is never guessed from the value, the key or an earlier result. """ @@ -474,15 +476,18 @@ def _rule_attribution(details: Optional[FeatureFlag]) -> Optional[_RuleAttributi if _v4_field(metadata, "config_version") != 2: return None reason_code = (details.reason.code if details.reason else None) or None - if _v4_field(details, "failed") is True or reason_code not in _RULE_REASON_CODES: + allowed_rule_types = _RULE_TYPES_BY_REASON_CODE.get(reason_code or "") + if _v4_field(details, "failed") is True or allowed_rule_types is None: return _RuleAttribution(reason_code, None, None, None) rule_id = _v4_field(metadata, "rule_id") or None rule_type = _v4_field(metadata, "rule_type") or None + if rule_id is None or rule_type not in allowed_rule_types: + return _RuleAttribution(reason_code, None, None, None) if reason_code != _EXPERIMENT_SPLIT_REASON: # Only an experiment split assigns a variant. return _RuleAttribution(reason_code, rule_id, rule_type, None) variant_key = _v4_field(metadata, "variant_key") or None - if rule_type != _EXPERIMENT_RULE_TYPE or rule_id is None or variant_key is None: + if variant_key is None: return _RuleAttribution(reason_code, None, None, None) return _RuleAttribution(reason_code, rule_id, rule_type, variant_key) diff --git a/posthog/test/test_experiment_exposure.py b/posthog/test/test_experiment_exposure.py index 98b1b4546..a7357c933 100644 --- a/posthog/test/test_experiment_exposure.py +++ b/posthog/test/test_experiment_exposure.py @@ -597,6 +597,72 @@ def test_rule_matches_misses_pauses_and_holdouts_name_their_rule(code, rule_type FLAG_CALLED_SCHEMA.validate(_projection(FLAG_CALLED_SCHEMA, called.properties)) +# The rule ID and rule type are sent together, with a rule type the reason allows, or +# not at all. Contract 8.0.0 accepts such a call without its rule pair only for a +# malformed split, so these calls are not validated against the vendored schema +# until a harness release that accepts them is copied. +BROKEN_RULE_PAIR_CASES = [ + # Half pairs. + ("targeting_match", {"rule_id": RULE}), + ("rollout_miss", {"rule_id": RULE}), + ("experiment_paused", {"rule_id": RULE}), + ("holdout", {"rule_id": RULE}), + ("targeting_match", {"rule_type": "targeted_release"}), + ("rollout_miss", {"rule_type": "percentage_rollout"}), + ("experiment_paused", {"rule_type": "experiment"}), + ("holdout", {"rule_type": "experiment"}), + # Rule types the reason code does not allow. + ("targeting_match", {"rule_id": RULE, "rule_type": "experiment"}), + ("rollout_miss", {"rule_id": RULE, "rule_type": "targeted_release"}), + ("experiment_paused", {"rule_id": RULE, "rule_type": "percentage_rollout"}), + ("holdout", {"rule_id": RULE, "rule_type": "targeted_release"}), + # A rule type this SDK does not know. + ("targeting_match", {"rule_id": RULE, "rule_type": "future_rule_type"}), +] + + +@pytest.mark.parametrize("path", ["get_feature_flag_result", "snapshot.get_flag"]) +@pytest.mark.parametrize( + "code, fields", + BROKEN_RULE_PAIR_CASES, + ids=[f"{c}-{sorted(f.items())}" for c, f in BROKEN_RULE_PAIR_CASES], +) +def test_a_half_or_disallowed_rule_pair_is_not_sent(code, fields, path): + record = _record(code, False, **fields) + + events = _read_events(path, _response(record)) + + [called] = _named(events, FLAG_CALLED) + assert _attribution(called.properties) == { + "$feature_flag_config_version": 2, + "$feature_flag_reason_code": code, + } + assert _named(events, EXPOSURE) == [] + + +@pytest.mark.parametrize("path", list(SYNC_READS)) +def test_a_targeting_match_that_names_an_experiment_rule_sends_no_exposure(path): + # Even with a variant key and a linked experiment, a targeting match assigns no + # arm, so it reports no rule and sends no exposure. + record = _record( + "targeting_match", + "annual", + rule_type="experiment", + rule_id=RULE, + variant_key="annual-first", + has_experiment=True, + ) + + events = _read_events(path, _response(record)) + + [called] = _named(events, FLAG_CALLED) + assert _attribution(called.properties) == { + "$feature_flag_config_version": 2, + "$feature_flag_reason_code": "targeting_match", + } + assert _named(events, EXPOSURE) == [] + + def test_an_older_server_record_reports_no_attribution(): # A record without a typed value comes from a server before /flags?v=4. Its # metadata is not read as config version 2, whatever it says. From a5b6c34d57a8c24de870d0f8c5bed19f4670923c Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Wed, 4 Nov 2026 13:56:44 +0100 Subject: [PATCH 32/34] fix(flags): keep exposure dedupe only for accepted captures Follow the updated PostHog/sdk-specs#162. - A config version 2 $feature_flag_called and every $experiment_exposure keep their dedupe key only when capture() returns an event UUID without raising. The key is reserved while capture runs, so concurrent reads do not send the event twice, and released when capture drops the event (before_send, opt-out, a full or closed queue) or raises. A raising capture is logged, the read returns its value, and the other event is still attempted. Config version 1 calls keep their existing dedupe. - A rule ID counts as present only in the hyphenated 8-4-4-4-12 UUID form. Any other string drops the rule pair and sends no exposure. - A variant key is present when it is a string, so an empty variant key completes a split and sends the exposure. - Super properties are merged before the attribution of both config version 2 events is set again from the record. A super property can no longer change the rule ID or variant, or add the reason description, a forced variant, an experiment ID, or exposure-forbidden properties. Config version 1 events still take super properties as before. Tests cover unknown and missing reason codes, non-UUID rule IDs, empty variant keys, a dropped and a raising capture of each event followed by a second read, unchanged config version 1 dedupe, a flags reload, and super properties on both clients. The scenario and parity mocks of _enqueue now return an event UUID, as the real queue does. --- .sampo/changesets/experiment-exposure.md | 2 +- posthog/args.py | 20 ++ posthog/async_client.py | 20 +- posthog/client.py | 188 ++++++++++++--- posthog/test/released_flags_scenarios.py | 4 +- posthog/test/test_experiment_exposure.py | 287 +++++++++++++++++++++++ posthog/test/test_flags_v4_response.py | 3 +- 7 files changed, 479 insertions(+), 45 deletions(-) diff --git a/.sampo/changesets/experiment-exposure.md b/.sampo/changesets/experiment-exposure.md index aa17cf282..82d9ba891 100644 --- a/.sampo/changesets/experiment-exposure.md +++ b/.sampo/changesets/experiment-exposure.md @@ -2,4 +2,4 @@ pypi/posthog: minor --- -Send `$experiment_exposure` when a remotely evaluated config version 2 flag assigns an experiment variant. The SDK sends it together with `$feature_flag_called`, from `get_feature_flag()`, `feature_enabled()`, `get_feature_flag_result()`, and the `is_enabled()` and `get_flag()` reads of an `evaluate_flags()` snapshot. It carries the flag key, the same `$feature_flag_response` as its `$feature_flag_called`, the variant key and rule ID, `locally_evaluated: false`, and the flag ID, version, request ID and evaluation time when the response has them. It is sent once per distinct ID, groups, flag, rule and variant, whatever value or request it came from. `$feature_flag_called` for a config version 2 flag gets `$feature_flag_config_version` and `$feature_flag_reason_code`, which replaces the human-readable `$feature_flag_reason`. A targeting match, rollout miss, paused experiment or holdout adds the `$feature_flag_rule_id` and `$feature_flag_rule_type` it names, and a complete experiment split adds these and `$feature_flag_variant`. The rule ID and rule type are sent together, and only with a rule type the reason allows: `targeted_release` or `percentage_rollout` for a targeting match, `percentage_rollout` or `experiment` for a rollout miss, and `experiment` for a paused experiment, a holdout or a split. A record with no rule ID, no rule type or another rule type, such as a targeting match that names an experiment rule, sends neither and no exposure. Other outcomes, including failed flags, add no rule properties. Minimal `$feature_flag_called` events keep these properties, and their dedupe includes them, so equal-valued arms and successive experiments of one flag are each reported. A split that lacks its rule type, rule ID or variant key sends no exposure and reports no rule or variant. A `get_feature_flag_payload()` lookup with `match_value` reports the caller's value, so it sends no exposure. `send_feature_flag_events=False` suppresses both events. Events of config version 1 flags, flags from servers without typed values and locally evaluated flags are unchanged, including their `$feature_flag_reason`, and `$feature_flag_response` is unchanged on every event. +Send `$experiment_exposure` when a remotely evaluated config version 2 flag assigns an experiment variant. The SDK sends it together with `$feature_flag_called`, from `get_feature_flag()`, `feature_enabled()`, `get_feature_flag_result()`, and the `is_enabled()` and `get_flag()` reads of an `evaluate_flags()` snapshot. It carries the flag key, the same `$feature_flag_response` as its `$feature_flag_called`, the variant key and rule ID, `locally_evaluated: false`, and the flag ID, version, request ID and evaluation time when the response has them. It is sent once per distinct ID, groups, flag, rule and variant, whatever value or request it came from. `$feature_flag_called` for a config version 2 flag gets `$feature_flag_config_version` and `$feature_flag_reason_code`, which replaces the human-readable `$feature_flag_reason`. A targeting match, rollout miss, paused experiment or holdout adds the `$feature_flag_rule_id` and `$feature_flag_rule_type` it names, and a complete experiment split adds these and `$feature_flag_variant`. The rule ID and rule type are sent together, and only with a rule type the reason allows: `targeted_release` or `percentage_rollout` for a targeting match, `percentage_rollout` or `experiment` for a rollout miss, and `experiment` for a paused experiment, a holdout or a split. A record with no rule ID, no rule type or another rule type, such as a targeting match that names an experiment rule, sends neither and no exposure. Other outcomes, including failed flags, add no rule properties. Minimal `$feature_flag_called` events keep these properties, and their dedupe includes them, so equal-valued arms and successive experiments of one flag are each reported. A split that lacks its rule type, rule ID or variant key sends no exposure and reports no rule or variant. A rule ID counts only in the hyphenated UUID form; any other string counts as missing. An empty variant key is present, so a split with one sends the exposure. A config version 2 flag with an unknown or missing reason code still reports its config version, and its code when it has one, with no rule properties and no exposure. Super properties cannot add, remove or change the attribution of config version 2 events. A config version 2 event that capture drops or fails to send keeps no dedupe entry, so a later read sends it, and one event failing does not stop the other. A flags reload does not reset exposure dedupe. A `get_feature_flag_payload()` lookup with `match_value` reports the caller's value, so it sends no exposure. `send_feature_flag_events=False` suppresses both events. Events of config version 1 flags, flags from servers without typed values and locally evaluated flags are unchanged, including their `$feature_flag_reason`, and `$feature_flag_response` is unchanged on every event. diff --git a/posthog/args.py b/posthog/args.py index 42083b923..61f566483 100644 --- a/posthog/args.py +++ b/posthog/args.py @@ -5,6 +5,8 @@ Any, Dict, FrozenSet, + Mapping, + NamedTuple, Union, Tuple, Type, @@ -65,6 +67,24 @@ class OptionalCaptureArgs(TypedDict): _property_allowlist: NotRequired[ Optional[FrozenSet[str]] ] # Internal: strict allowlist applied to the fully-enriched event properties. Used by minimal $feature_flag_called events. + _pinned_properties: NotRequired[ + Optional["_PinnedProperties"] + ] # Internal: properties fixed after super properties are merged. Used by config version 2 flag events. + + +class _PinnedProperties(NamedTuple): + """Internal: event properties that super properties cannot change. + + Applied to the fully-enriched properties, after super properties are merged and + before any allowlist: ``removed`` names are dropped, then ``values`` are set. + """ + + removed: FrozenSet[str] + values: Mapping[str, Any] + + def apply(self, properties: Dict[str, Any]) -> Dict[str, Any]: + kept = {k: v for k, v in properties.items() if k not in self.removed} + return {**kept, **self.values} class OptionalSetArgs(TypedDict): diff --git a/posthog/async_client.py b/posthog/async_client.py index ddaa18057..10bdad53e 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -467,6 +467,7 @@ def _prepare_event( msg: dict[str, Any], disable_geoip: Optional[bool], property_allowlist=None, + pinned_properties=None, ) -> tuple[Optional[dict[str, Any]], Optional[str]]: if self.disabled or not self._accepting: return None, None @@ -498,6 +499,10 @@ def _prepare_event( msg["properties"].setdefault("$release_id", self._release_id) if self.is_server: msg["properties"]["$is_server"] = True + # After every enrichment step, so a super property never changes or adds + # config version 2 flag attribution. + if pinned_properties is not None: + msg["properties"] = pinned_properties.apply(msg["properties"]) if property_allowlist is not None: msg["properties"] = { key: value @@ -530,7 +535,7 @@ async def _process_event(self, msg: dict[str, Any]) -> Optional[dict[str, Any]]: def _build_capture_event( self, event: str, kwargs: OptionalCaptureArgs - ) -> tuple[dict[str, Any], Optional[bool], Any]: + ) -> tuple[dict[str, Any], Optional[bool], Any, Any]: properties = {**(kwargs.get("properties") or {}), **system_context()} if self.capture_trace_context: properties = {**_get_current_otel_span_properties(), **properties} @@ -566,6 +571,7 @@ def _build_capture_event( }, kwargs.get("disable_geoip"), kwargs.get("_property_allowlist"), + kwargs.get("_pinned_properties"), ) def capture( @@ -573,11 +579,11 @@ def capture( ) -> Optional[str]: """Queue an event without blocking for network delivery.""" try: - msg, disable_geoip, property_allowlist = self._build_capture_event( - event, kwargs + msg, disable_geoip, property_allowlist, pinned_properties = ( + self._build_capture_event(event, kwargs) ) prepared, sent_uuid = self._prepare_event( - msg, disable_geoip, property_allowlist + msg, disable_geoip, property_allowlist, pinned_properties ) if prepared is None or sent_uuid is None: return None @@ -621,11 +627,11 @@ async def capture_immediate( self._immediate_callers[current] = self._immediate_callers.get(current, 0) + 1 error_batch: list[dict[str, Any]] = [] try: - msg, disable_geoip, property_allowlist = self._build_capture_event( - event, kwargs + msg, disable_geoip, property_allowlist, pinned_properties = ( + self._build_capture_event(event, kwargs) ) prepared, sent_uuid = self._prepare_event( - msg, disable_geoip, property_allowlist + msg, disable_geoip, property_allowlist, pinned_properties ) if prepared is None or sent_uuid is None: return None diff --git a/posthog/client.py b/posthog/client.py index d3b725a22..731b9f503 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -4,6 +4,7 @@ import json import logging import os +import re import sys import threading import time @@ -20,6 +21,7 @@ Mapping, NamedTuple, Optional, + Set, Tuple, Union, cast, @@ -30,7 +32,13 @@ from posthog._async_utils import _BackgroundEventLoopRunner from posthog._disabled_lane_queue import _DisabledLaneQueue -from posthog.args import ID_TYPES, ExceptionArg, OptionalCaptureArgs, OptionalSetArgs +from posthog.args import ( + ID_TYPES, + ExceptionArg, + OptionalCaptureArgs, + OptionalSetArgs, + _PinnedProperties, +) from posthog.metrics_capture import ( _DEFAULT_FLUSH_INTERVAL_SECONDS, PostHogMetrics, @@ -407,6 +415,34 @@ def _metadata_has_experiment(metadata: Any) -> Optional[bool]: "holdout": frozenset({_EXPERIMENT_RULE_TYPE}), } +# Properties a super property must not add to, or change on, a config version 2 +# ``$feature_flag_called``: the attribution, which is set again from the record +# after super properties are merged, and the fields such an event never carries. +_V2_FLAG_CALLED_PINNED = frozenset( + { + "$feature_flag_config_version", + "$feature_flag_reason_code", + "$feature_flag_rule_id", + "$feature_flag_rule_type", + "$feature_flag_variant", + "$feature_flag_reason", + "$feature_flag_forced_variant", + "$feature_flag_experiment_id", + } +) + +# Properties an ``$experiment_exposure`` never carries, even from a super property. +_EXPOSURE_FORBIDDEN = frozenset( + { + "$feature_flag_config_version", + "$feature_flag_reason_code", + "$feature_flag_rule_type", + "$experiment_exposure_source", + "$feature_flag_experiment_id", + "$feature_flag_forced_variant", + } +) + # ``$experiment_exposure`` copies these from the ``$feature_flag_called`` of the same # read, when it has them. _EXPOSURE_PROPERTIES_FROM_FLAG_CALLED = ( @@ -428,6 +464,17 @@ def _v4_field(record: Any, name: str) -> Any: return getattr(record, name, getattr(record, f"_{name}", None)) +# The rule ID's wire format: a hyphenated UUID, as the contract's event schemas +# require. +_UUID_PATTERN = re.compile( + r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" +) + + +def _is_uuid(value: Any) -> bool: + return isinstance(value, str) and _UUID_PATTERN.fullmatch(value) is not None + + class _RuleAttribution(NamedTuple): """What a config version 2 flag record reports on its flag events.""" @@ -462,13 +509,15 @@ def _rule_attribution(details: Optional[FeatureFlag]) -> Optional[_RuleAttributi miss, a paused experiment, a holdout and a complete experiment split, and only with a rule type the reason code allows (``_RULE_TYPES_BY_REASON_CODE``). A record with no rule ID, no rule type or another rule type, such as a targeting - match that names an experiment rule, reports neither. So does a failed record or - any other reason code. The variant key is reported only for a complete - experiment split: reason ``experiment_split``, rule type ``experiment``, a rule - ID and a variant key. A split without all of them is malformed. A record that - reports no rule keeps its reason, reports no rule ID, rule type or variant, and - sends no exposure. Its identity is never guessed from the value, the key or an - earlier result. + match that names an experiment rule, reports neither. A rule ID that is not a + UUID counts as missing. So does a failed record or any other reason code, + including one this SDK does not know, and a record with no reason code. The + variant key is reported only for a complete experiment split: reason + ``experiment_split``, rule type ``experiment``, a rule ID and a variant key (any + string, including an empty one). A split without all of them is malformed. A + record that reports no rule keeps its reason, reports no rule ID, rule type or + variant, and sends no exposure. Its identity is never guessed from the value, + the key or an earlier result. """ if details is None or not isinstance(details.metadata, FlagMetadata): return None @@ -479,19 +528,46 @@ def _rule_attribution(details: Optional[FeatureFlag]) -> Optional[_RuleAttributi allowed_rule_types = _RULE_TYPES_BY_REASON_CODE.get(reason_code or "") if _v4_field(details, "failed") is True or allowed_rule_types is None: return _RuleAttribution(reason_code, None, None, None) - rule_id = _v4_field(metadata, "rule_id") or None - rule_type = _v4_field(metadata, "rule_type") or None - if rule_id is None or rule_type not in allowed_rule_types: + rule_id = _v4_field(metadata, "rule_id") + rule_type = _v4_field(metadata, "rule_type") + if not _is_uuid(rule_id) or rule_type not in allowed_rule_types: return _RuleAttribution(reason_code, None, None, None) if reason_code != _EXPERIMENT_SPLIT_REASON: # Only an experiment split assigns a variant. return _RuleAttribution(reason_code, rule_id, rule_type, None) - variant_key = _v4_field(metadata, "variant_key") or None - if variant_key is None: + variant_key = _v4_field(metadata, "variant_key") + if not isinstance(variant_key, str): return _RuleAttribution(reason_code, None, None, None) return _RuleAttribution(reason_code, rule_id, rule_type, variant_key) +_flag_events_log = logging.getLogger("posthog") + + +def _capture_once( + capture: Callable[..., Any], + reported: Set[Any], + dedupe_key: Any, + event: str, + **kwargs: Any, +) -> None: + """Capture one flag event and keep its dedupe key only if capture accepted it. + + The key is reserved before the capture, so concurrent reads do not send the + same event twice, and released again when capture drops the event (returns + ``None``, for example from ``before_send`` or a closed queue) or raises. A + failure is logged and never reaches the read. + """ + reported.add(dedupe_key) + try: + accepted = capture(event, **kwargs) is not None + except Exception: + _flag_events_log.exception("[FEATURE FLAGS] Failed to capture %s", event) + accepted = False + if not accepted: + reported.discard(dedupe_key) + + def _capture_flag_events( capture: Callable[..., Any], flag_calls_reported: Dict[Any, Any], @@ -542,6 +618,12 @@ def _capture_flag_events( split whose record supplied the reported value (``value_from_details``), once per distinct ID, groups, flag, rule and variant. It has its own dedupe, so a ``$feature_flag_called`` already reported never suppresses it. + + Both config version 2 events are attempted independently: a failed or dropped + capture of one does not stop the other, and keeps no dedupe key, so a later read + tries again. Their attribution is pinned after super properties are merged, so + a super property can neither change it nor add a field the event never carries. + Config version 1 events are sent and deduped as before. """ groups_key = ( tuple(sorted((str(k), str(v)) for k, v in groups.items())) if groups else () @@ -579,15 +661,32 @@ def _capture_flag_events( else _MINIMAL_V2_FLAG_CALLED_EVENT_PROPERTIES ) - capture( - "$feature_flag_called", - distinct_id=distinct_id, - properties=properties, - groups=capture_groups, - disable_geoip=disable_geoip, - **extra_capture_kwargs, - ) - reported_flags.add(feature_flag_reported_key) + if attribution is None: + # Config version 1 events are sent and deduped as before. + capture( + "$feature_flag_called", + distinct_id=distinct_id, + properties=properties, + groups=capture_groups, + disable_geoip=disable_geoip, + **extra_capture_kwargs, + ) + reported_flags.add(feature_flag_reported_key) + else: + _capture_once( + capture, + reported_flags, + feature_flag_reported_key, + "$feature_flag_called", + distinct_id=distinct_id, + properties=properties, + groups=capture_groups, + disable_geoip=disable_geoip, + _pinned_properties=_PinnedProperties( + _V2_FLAG_CALLED_PINNED, attribution.flag_called_properties() + ), + **extra_capture_kwargs, + ) if attribution is None or attribution.variant_key is None or not value_from_details: return @@ -608,23 +707,25 @@ def _capture_flag_events( for name in _EXPOSURE_PROPERTIES_FROM_FLAG_CALLED if name in properties } - exposure_properties.update( - { - "$feature_flag": key, - "$feature_flag_response": response, - "$feature_flag_variant": attribution.variant_key, - "$feature_flag_rule_id": attribution.rule_id, - "locally_evaluated": properties.get("locally_evaluated") is True, - } - ) - capture( + exposure_identity = { + "$feature_flag": key, + "$feature_flag_response": response, + "$feature_flag_variant": attribution.variant_key, + "$feature_flag_rule_id": attribution.rule_id, + "locally_evaluated": properties.get("locally_evaluated") is True, + } + exposure_properties.update(exposure_identity) + _capture_once( + capture, + reported_exposures, + exposure_key, "$experiment_exposure", distinct_id=distinct_id, properties=exposure_properties, groups=capture_groups, disable_geoip=disable_geoip, + _pinned_properties=_PinnedProperties(_EXPOSURE_FORBIDDEN, exposure_identity), ) - reported_exposures.add(exposure_key) class _Lane: @@ -2102,6 +2203,7 @@ def _capture( # Internal, set for minimal $feature_flag_called events: a strict allowlist # applied to the fully-enriched properties dict just before enqueueing. property_allowlist = kwargs.get("_property_allowlist", None) + pinned_properties = kwargs.get("_pinned_properties", None) properties = {**(properties or {}), **system_context()} @@ -2219,7 +2321,11 @@ def _capture( msg["properties"] = properties return self._enqueue( - msg, disable_geoip, lane, property_allowlist=property_allowlist + msg, + disable_geoip, + lane, + property_allowlist=property_allowlist, + pinned_properties=pinned_properties, ) def _parse_send_feature_flags(self, send_feature_flags) -> SendFeatureFlagsOptions: @@ -2828,7 +2934,14 @@ def _normalize_event_uuid(self, msg): # Always send a uuid, so we can always return one msg["uuid"] = stringify_id(uuid4()) - def _enqueue(self, msg, disable_geoip, lane=None, property_allowlist=None): + def _enqueue( + self, + msg, + disable_geoip, + lane=None, + property_allowlist=None, + pinned_properties=None, + ): # type: (...) -> Optional[str] """Push a new `msg` onto a lane's queue (analytics when unspecified), return the event uuid or None.""" @@ -2880,6 +2993,11 @@ def _enqueue(self, msg, disable_geoip, lane=None, property_allowlist=None): # Applied after every enrichment step (system context, context tags, super # properties, $lib/$lib_version) so the final event shape is exactly the # allowlist regardless of where a property came from. + # After every enrichment step, so a super property never changes or adds + # config version 2 flag attribution. + if pinned_properties is not None: + msg["properties"] = pinned_properties.apply(msg["properties"]) + if property_allowlist is not None: msg["properties"] = { k: v for k, v in msg["properties"].items() if k in property_allowlist diff --git a/posthog/test/released_flags_scenarios.py b/posthog/test/released_flags_scenarios.py index b3cacdac1..dfbf629bc 100644 --- a/posthog/test/released_flags_scenarios.py +++ b/posthog/test/released_flags_scenarios.py @@ -31,6 +31,7 @@ ) API_KEY = "phc_released_flags_scenarios" DISTINCT_ID = "user-1" +ACCEPTED_UUID = "70000000-0000-4000-8000-000000000000" REQUEST_ID = "60000000-0000-4000-8000-000000000000" EVALUATED_AT = 1800000000000 MATCHED = { @@ -170,7 +171,8 @@ def flags(*args: Any, **kwargs: Any) -> Any: def enqueue(message: Dict[str, Any], *args: Any, **kwargs: Any) -> Optional[str]: events.append([message["event"], _flag_event_properties(message)]) - return None + # Accept the event, as the real queue does, by returning an event UUID. + return ACCEPTED_UUID try: with ( diff --git a/posthog/test/test_experiment_exposure.py b/posthog/test/test_experiment_exposure.py index a7357c933..1fc8bb4e9 100644 --- a/posthog/test/test_experiment_exposure.py +++ b/posthog/test/test_experiment_exposure.py @@ -1320,3 +1320,290 @@ def before_send(message: Dict[str, Any]) -> Dict[str, Any]: assert exposure["event"] == EXPOSURE assert exposure["properties"]["plan"] == "team" assert _attribution(exposure["properties"]) == _attribution(EXPECTED_EXPOSURE) + + +# --- Reason codes, rule IDs and variant keys --------------------------------------------- + + +@pytest.mark.parametrize("path", ["get_feature_flag_result", "snapshot.get_flag"]) +@pytest.mark.parametrize("code", ["future_reason", None], ids=["unknown", "missing"]) +def test_an_unknown_or_missing_reason_code_still_reports_config_version_2(code, path): + record = _split() + if code is None: + del record["reason"] + else: + record["reason"]["code"] = code + + events = _read_events(path, _response(record)) + + [called] = _named(events, FLAG_CALLED) + expected: Dict[str, Any] = {"$feature_flag_config_version": 2} + if code is not None: + expected["$feature_flag_reason_code"] = code + assert _attribution(called.properties) == expected + assert "$feature_flag_reason" not in called.properties + assert _named(events, EXPOSURE) == [] + + +@pytest.mark.skip( + reason="Contract 8.0.0 requires a registered reason code on a config version 2" + " call. Turn this on once a harness release with contract 9.0.0" + " (PostHog/posthog-sdk-test-harness#101) is vendored." +) +@pytest.mark.parametrize("code", ["future_reason", None], ids=["unknown", "missing"]) +def test_a_call_with_an_unknown_or_missing_reason_code_matches_the_contract(code): + record = _split() + if code is None: + del record["reason"] + else: + record["reason"]["code"] = code + + events = _read_events("get_feature_flag_result", _response(record)) + + [called] = _named(events, FLAG_CALLED) + FLAG_CALLED_SCHEMA.validate(_projection(FLAG_CALLED_SCHEMA, called.properties)) + + +NOT_A_UUID = ["rule-1", "", "7F3C2A108D4E4B6A9C215E0F1A2B3C4D", "{" + RULE + "}"] + + +@pytest.mark.parametrize("path", ["get_feature_flag_result", "snapshot.get_flag"]) +@pytest.mark.parametrize("rule_id", NOT_A_UUID) +def test_a_rule_id_that_is_not_a_uuid_counts_as_missing(rule_id, path): + for record in ( + _split(rule_id=rule_id), + _record("targeting_match", rule_type="targeted_release", rule_id=rule_id), + ): + events = _read_events(path, _response(record)) + + [called] = _named(events, FLAG_CALLED) + assert _attribution(called.properties) == { + "$feature_flag_config_version": 2, + "$feature_flag_reason_code": record["reason"]["code"], + } + assert _named(events, EXPOSURE) == [] + + +def test_an_upper_case_uuid_rule_id_is_kept(): + events = _read_events( + "get_feature_flag_result", _response(_split(rule_id=RULE.upper())) + ) + + [exposure] = _named(events, EXPOSURE) + assert exposure.properties["$feature_flag_rule_id"] == RULE.upper() + + +@pytest.mark.parametrize("path", list(SYNC_READS)) +def test_an_empty_variant_key_is_a_complete_split(path): + events = _read_events(path, _response(_split(variant_key=""))) + + [called] = _named(events, FLAG_CALLED) + assert called.properties["$feature_flag_variant"] == "" + [exposure] = _named(events, EXPOSURE) + assert exposure.properties["$feature_flag_variant"] == "" + EXPOSURE_SCHEMA.validate(_projection(EXPOSURE_SCHEMA, exposure.properties)) + + +# --- Capture failures ---------------------------------------------------------------------- + + +def _dropping(event: str) -> Callable[[Dict[str, Any]], Optional[Dict[str, Any]]]: + """A before_send hook that drops the first ``event`` and keeps everything else.""" + dropped: List[str] = [] + + def before_send(message: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if message["event"] == event and not dropped: + dropped.append(event) + return None + return message + + return before_send + + +def _raising_once(client: Client, event: str) -> Any: + """Patch ``client.capture`` to raise for the first ``event``, then capture as usual.""" + capture = client.capture + raised: List[str] = [] + + def maybe_raise(name: str, **kwargs: Any) -> Optional[str]: + if name == event and not raised: + raised.append(name) + raise RuntimeError("capture failed") + return capture(name, **kwargs) + + return mock.patch.object(client, "capture", side_effect=maybe_raise) + + +def _sent(client: Client, path: str, reads: int = 2) -> List[str]: + """The events that reached the queue over ``reads`` reads of a complete split.""" + sent: List[str] = [] + enqueue = client._enqueue + + def record(msg: Dict[str, Any], *args: Any, **kwargs: Any) -> Optional[str]: + uuid = enqueue(msg, *args, **kwargs) + if uuid is not None: + sent.append(msg["event"]) + return uuid + + with ( + _serving(_response(_split())), + mock.patch.object(client, "_enqueue", side_effect=record), + ): + for _ in range(reads): + assert _read(client, path) == "annual" + return sent + + +@pytest.mark.parametrize("path", ["get_feature_flag", "snapshot.get_flag"]) +@pytest.mark.parametrize("event", [FLAG_CALLED, EXPOSURE]) +def test_a_dropped_capture_keeps_no_dedupe_key(event, path): + client = Client(FAKE_TEST_API_KEY, send=False, before_send=_dropping(event)) + try: + sent = _sent(client, path) + finally: + client.shutdown() + + # The other event of the first read is still sent, and the dropped one is sent + # again by the second read, once. + assert sorted(sent) == sorted([FLAG_CALLED, EXPOSURE]) + + +@pytest.mark.parametrize("path", ["get_feature_flag", "snapshot.get_flag"]) +@pytest.mark.parametrize("event", [FLAG_CALLED, EXPOSURE]) +def test_a_raising_capture_keeps_no_dedupe_key(event, path, client): + with _raising_once(client, event): + sent = _sent(client, path) + + assert sorted(sent) == sorted([FLAG_CALLED, EXPOSURE]) + + +def test_a_dropped_config_version_1_call_is_deduped_as_before(): + client = Client(FAKE_TEST_API_KEY, send=False, before_send=_dropping(FLAG_CALLED)) + recording, events = _record_events(client) + record = _record("condition_match", "annual", config_version=1) + try: + with _serving(_response(record)), recording: + _read(client, "get_feature_flag") + _read(client, "get_feature_flag") + finally: + client.shutdown() + + # Config version 1 dedupe is unchanged: the attempt takes the key. + assert [event.name for event in events] == [FLAG_CALLED] + + +def test_a_flags_reload_does_not_repeat_an_exposure(client): + recording, events = _record_events(client) + with _serving(_response(_split())), recording: + _read(client, "get_feature_flag") + client._update_flag_state( + {"flags": [], "group_type_mapping": {}, "cohorts": {}} + ) + _read(client, "get_feature_flag") + client.evaluate_flags(DISTINCT_ID).get_flag(FLAG) + + assert len(_named(events, EXPOSURE)) == 1 + + +# --- Super properties ------------------------------------------------------------------------ + +SPOOFED_SUPER_PROPERTIES = { + "$feature_flag_config_version": 7, + "$feature_flag_reason_code": "spoofed", + "$feature_flag_rule_type": "spoofed", + "$feature_flag_rule_id": OTHER_RULE, + "$feature_flag_variant": "spoofed", + "$feature_flag_reason": "spoofed", + "$feature_flag_forced_variant": True, + "$feature_flag_experiment_id": 301, + "$experiment_exposure_source": "spoofed", +} + + +def _messages_with_super_properties(record: Dict[str, Any]) -> List[Dict[str, Any]]: + messages: List[Dict[str, Any]] = [] + + def before_send(message: Dict[str, Any]) -> Dict[str, Any]: + messages.append(copy.deepcopy(message)) + return message + + client = Client( + FAKE_TEST_API_KEY, + send=False, + before_send=before_send, + super_properties=dict(SPOOFED_SUPER_PROPERTIES), + ) + try: + with _serving(_response(record)): + _read(client, "get_feature_flag") + finally: + client.shutdown() + return messages + + +def test_super_properties_cannot_change_config_version_2_attribution(): + messages = _messages_with_super_properties(_split()) + + [called] = [m for m in messages if m["event"] == FLAG_CALLED] + assert _attribution(called["properties"]) == EXPECTED_ATTRIBUTION + for name in ( + "$feature_flag_reason", + "$feature_flag_forced_variant", + "$feature_flag_experiment_id", + ): + assert name not in called["properties"], name + [exposure] = [m for m in messages if m["event"] == EXPOSURE] + assert _attribution(exposure["properties"]) == _attribution(EXPECTED_EXPOSURE) + assert not set(EXPOSURE_LEAVES_OUT) & set(exposure["properties"]) + for name in ("$feature_flag_experiment_id", "$feature_flag_forced_variant"): + assert name not in exposure["properties"], name + EXPOSURE_SCHEMA.validate(_projection(EXPOSURE_SCHEMA, exposure["properties"])) + + +def test_super_properties_cannot_add_rule_fields_a_config_version_2_call_omits(): + messages = _messages_with_super_properties(_record("no_rule_match", False)) + + [called] = messages + assert _attribution(called["properties"]) == { + "$feature_flag_config_version": 2, + "$feature_flag_reason_code": "no_rule_match", + } + + +def test_super_properties_still_apply_to_config_version_1_calls(): + record = _record("condition_match", "annual", config_version=1) + [called] = _messages_with_super_properties(record) + + for name, value in SPOOFED_SUPER_PROPERTIES.items(): + assert called["properties"][name] == value, name + + +async def test_async_super_properties_cannot_change_config_version_2_attribution(): + messages: List[Dict[str, Any]] = [] + with mock.patch( + "posthog.async_client._async_flags", + new=mock.AsyncMock(return_value=_response(_split())), + ): + client = AsyncPosthog( + "project-key", + send=False, + super_properties=dict(SPOOFED_SUPER_PROPERTIES), + ) + prepare = client._prepare_event + + def record(*args: Any, **kwargs: Any) -> Any: + prepared, uuid = prepare(*args, **kwargs) + messages.append(copy.deepcopy(prepared)) + return prepared, uuid + + with mock.patch.object(client, "_prepare_event", side_effect=record): + snapshot = await client.evaluate_flags(DISTINCT_ID) + snapshot.get_flag(FLAG) + await client.shutdown() + + [called] = [m for m in messages if m["event"] == FLAG_CALLED] + assert _attribution(called["properties"]) == EXPECTED_ATTRIBUTION + assert "$feature_flag_reason" not in called["properties"] + [exposure] = [m for m in messages if m["event"] == EXPOSURE] + assert _attribution(exposure["properties"]) == _attribution(EXPECTED_EXPOSURE) + assert not set(EXPOSURE_LEAVES_OUT) & set(exposure["properties"]) diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py index eae5a0bec..1b029e539 100644 --- a/posthog/test/test_flags_v4_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -1045,7 +1045,8 @@ def response(*args: Any, **kwargs: Any) -> Dict[str, Any]: def enqueue(message: Dict[str, Any], *args: Any, **kwargs: Any) -> Optional[str]: events.append((message["event"], _flag_event_properties(message))) - return None + # Accept the event, as the real queue does, by returning an event UUID. + return "70000000-0000-4000-8000-000000000000" try: with ( From be680d38788e8187f7c778ab6508416615a21ce0 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Wed, 4 Nov 2026 14:04:32 +0100 Subject: [PATCH 33/34] fix(flags): keep the dedupe key of an event before_send drops The owner settled this in PostHog/sdk-specs#162: an event that before_send drops uses up its dedupe key on both clients. The application chose to drop it, and every later read would be dropped the same way. A raising capture, a full or closed queue, opt-out and a disabled client still release the key, so a later read tries again. The async client already behaved this way, because its before_send runs after capture() has queued the event. On the sync client, _capture_once now passes a private _before_send_drops list through capture() to _enqueue, which records a before_send drop there. A before_send that raises still counts as a failed capture. Tests: a before_send drop of each event on the sync client, and a drop of the exposure on the async client with its worker running, are not sent again. A raising capture and an unqueued capture of each event are still sent by the second read. The config version 1 test now uses an unqueued capture. --- .sampo/changesets/experiment-exposure.md | 2 +- posthog/args.py | 4 + posthog/client.py | 26 +++++-- posthog/test/test_experiment_exposure.py | 94 ++++++++++++++++++------ 4 files changed, 93 insertions(+), 33 deletions(-) diff --git a/.sampo/changesets/experiment-exposure.md b/.sampo/changesets/experiment-exposure.md index 82d9ba891..001d475a3 100644 --- a/.sampo/changesets/experiment-exposure.md +++ b/.sampo/changesets/experiment-exposure.md @@ -2,4 +2,4 @@ pypi/posthog: minor --- -Send `$experiment_exposure` when a remotely evaluated config version 2 flag assigns an experiment variant. The SDK sends it together with `$feature_flag_called`, from `get_feature_flag()`, `feature_enabled()`, `get_feature_flag_result()`, and the `is_enabled()` and `get_flag()` reads of an `evaluate_flags()` snapshot. It carries the flag key, the same `$feature_flag_response` as its `$feature_flag_called`, the variant key and rule ID, `locally_evaluated: false`, and the flag ID, version, request ID and evaluation time when the response has them. It is sent once per distinct ID, groups, flag, rule and variant, whatever value or request it came from. `$feature_flag_called` for a config version 2 flag gets `$feature_flag_config_version` and `$feature_flag_reason_code`, which replaces the human-readable `$feature_flag_reason`. A targeting match, rollout miss, paused experiment or holdout adds the `$feature_flag_rule_id` and `$feature_flag_rule_type` it names, and a complete experiment split adds these and `$feature_flag_variant`. The rule ID and rule type are sent together, and only with a rule type the reason allows: `targeted_release` or `percentage_rollout` for a targeting match, `percentage_rollout` or `experiment` for a rollout miss, and `experiment` for a paused experiment, a holdout or a split. A record with no rule ID, no rule type or another rule type, such as a targeting match that names an experiment rule, sends neither and no exposure. Other outcomes, including failed flags, add no rule properties. Minimal `$feature_flag_called` events keep these properties, and their dedupe includes them, so equal-valued arms and successive experiments of one flag are each reported. A split that lacks its rule type, rule ID or variant key sends no exposure and reports no rule or variant. A rule ID counts only in the hyphenated UUID form; any other string counts as missing. An empty variant key is present, so a split with one sends the exposure. A config version 2 flag with an unknown or missing reason code still reports its config version, and its code when it has one, with no rule properties and no exposure. Super properties cannot add, remove or change the attribution of config version 2 events. A config version 2 event that capture drops or fails to send keeps no dedupe entry, so a later read sends it, and one event failing does not stop the other. A flags reload does not reset exposure dedupe. A `get_feature_flag_payload()` lookup with `match_value` reports the caller's value, so it sends no exposure. `send_feature_flag_events=False` suppresses both events. Events of config version 1 flags, flags from servers without typed values and locally evaluated flags are unchanged, including their `$feature_flag_reason`, and `$feature_flag_response` is unchanged on every event. +Send `$experiment_exposure` when a remotely evaluated config version 2 flag assigns an experiment variant. The SDK sends it together with `$feature_flag_called`, from `get_feature_flag()`, `feature_enabled()`, `get_feature_flag_result()`, and the `is_enabled()` and `get_flag()` reads of an `evaluate_flags()` snapshot. It carries the flag key, the same `$feature_flag_response` as its `$feature_flag_called`, the variant key and rule ID, `locally_evaluated: false`, and the flag ID, version, request ID and evaluation time when the response has them. It is sent once per distinct ID, groups, flag, rule and variant, whatever value or request it came from. `$feature_flag_called` for a config version 2 flag gets `$feature_flag_config_version` and `$feature_flag_reason_code`, which replaces the human-readable `$feature_flag_reason`. A targeting match, rollout miss, paused experiment or holdout adds the `$feature_flag_rule_id` and `$feature_flag_rule_type` it names, and a complete experiment split adds these and `$feature_flag_variant`. The rule ID and rule type are sent together, and only with a rule type the reason allows: `targeted_release` or `percentage_rollout` for a targeting match, `percentage_rollout` or `experiment` for a rollout miss, and `experiment` for a paused experiment, a holdout or a split. A record with no rule ID, no rule type or another rule type, such as a targeting match that names an experiment rule, sends neither and no exposure. Other outcomes, including failed flags, add no rule properties. Minimal `$feature_flag_called` events keep these properties, and their dedupe includes them, so equal-valued arms and successive experiments of one flag are each reported. A split that lacks its rule type, rule ID or variant key sends no exposure and reports no rule or variant. A rule ID counts only in the hyphenated UUID form; any other string counts as missing. An empty variant key is present, so a split with one sends the exposure. A config version 2 flag with an unknown or missing reason code still reports its config version, and its code when it has one, with no rule properties and no exposure. Super properties cannot add, remove or change the attribution of config version 2 events. A config version 2 event whose capture raises or cannot be queued (a full or closed queue, opt-out or a disabled client) keeps no dedupe entry, so a later read sends it, and one event failing does not stop the other. An event that `before_send` drops keeps its dedupe entry, so later reads do not offer it again. A flags reload does not reset exposure dedupe. A `get_feature_flag_payload()` lookup with `match_value` reports the caller's value, so it sends no exposure. `send_feature_flag_events=False` suppresses both events. Events of config version 1 flags, flags from servers without typed values and locally evaluated flags are unchanged, including their `$feature_flag_reason`, and `$feature_flag_response` is unchanged on every event. diff --git a/posthog/args.py b/posthog/args.py index 61f566483..aeaf5a1d8 100644 --- a/posthog/args.py +++ b/posthog/args.py @@ -5,6 +5,7 @@ Any, Dict, FrozenSet, + List, Mapping, NamedTuple, Union, @@ -70,6 +71,9 @@ class OptionalCaptureArgs(TypedDict): _pinned_properties: NotRequired[ Optional["_PinnedProperties"] ] # Internal: properties fixed after super properties are merged. Used by config version 2 flag events. + _before_send_drops: NotRequired[ + Optional[List[str]] + ] # Internal: gets the event name when before_send drops the event. Used by flag event dedupe. class _PinnedProperties(NamedTuple): diff --git a/posthog/client.py b/posthog/client.py index 731b9f503..47fe23bc9 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -551,16 +551,21 @@ def _capture_once( event: str, **kwargs: Any, ) -> None: - """Capture one flag event and keep its dedupe key only if capture accepted it. + """Capture one flag event and keep its dedupe key unless capture failed. The key is reserved before the capture, so concurrent reads do not send the - same event twice, and released again when capture drops the event (returns - ``None``, for example from ``before_send`` or a closed queue) or raises. A - failure is logged and never reaches the read. + same event twice. It is released when capture raises, or returns ``None`` + because the event could not be queued (a full or closed queue, opt-out or a + disabled client), so a later read tries again. An event that ``before_send`` + drops keeps its key: the application chose to drop it, and every later read + would be dropped the same way. A failure is logged and never reaches the read. """ reported.add(dedupe_key) + before_send_drops: List[str] = [] try: - accepted = capture(event, **kwargs) is not None + accepted = capture( + event, _before_send_drops=before_send_drops, **kwargs + ) is not None or bool(before_send_drops) except Exception: _flag_events_log.exception("[FEATURE FLAGS] Failed to capture %s", event) accepted = False @@ -619,9 +624,9 @@ def _capture_flag_events( per distinct ID, groups, flag, rule and variant. It has its own dedupe, so a ``$feature_flag_called`` already reported never suppresses it. - Both config version 2 events are attempted independently: a failed or dropped - capture of one does not stop the other, and keeps no dedupe key, so a later read - tries again. Their attribution is pinned after super properties are merged, so + Both config version 2 events are attempted independently: a failed capture of + one does not stop the other, and keeps no dedupe key, so a later read tries + again. An event that ``before_send`` drops keeps its key. Their attribution is pinned after super properties are merged, so a super property can neither change it nor add a field the event never carries. Config version 1 events are sent and deduped as before. """ @@ -2204,6 +2209,7 @@ def _capture( # applied to the fully-enriched properties dict just before enqueueing. property_allowlist = kwargs.get("_property_allowlist", None) pinned_properties = kwargs.get("_pinned_properties", None) + before_send_drops = kwargs.get("_before_send_drops", None) properties = {**(properties or {}), **system_context()} @@ -2326,6 +2332,7 @@ def _capture( lane, property_allowlist=property_allowlist, pinned_properties=pinned_properties, + before_send_drops=before_send_drops, ) def _parse_send_feature_flags(self, send_feature_flags) -> SendFeatureFlagsOptions: @@ -2941,6 +2948,7 @@ def _enqueue( lane=None, property_allowlist=None, pinned_properties=None, + before_send_drops=None, ): # type: (...) -> Optional[str] """Push a new `msg` onto a lane's queue (analytics when unspecified), return the event uuid or None.""" @@ -3012,6 +3020,8 @@ def _enqueue( modified_msg = self.before_send(msg) if modified_msg is None: self.log.debug("Event dropped by before_send callback") + if before_send_drops is not None: + before_send_drops.append(msg["event"]) return None if not isinstance(modified_msg, dict): raise TypeError("before_send must return a dict or None") diff --git a/posthog/test/test_experiment_exposure.py b/posthog/test/test_experiment_exposure.py index 1fc8bb4e9..b69b2bbb8 100644 --- a/posthog/test/test_experiment_exposure.py +++ b/posthog/test/test_experiment_exposure.py @@ -1420,18 +1420,25 @@ def before_send(message: Dict[str, Any]) -> Optional[Dict[str, Any]]: return before_send -def _raising_once(client: Client, event: str) -> Any: - """Patch ``client.capture`` to raise for the first ``event``, then capture as usual.""" +def _failing_once(client: Client, event: str, failure: str) -> Any: + """Patch ``client.capture`` to fail for the first ``event``, then capture as usual. + + ``failure`` is ``"raise"`` for a raising capture, or ``"unqueued"`` for a capture + that returns ``None`` without ``before_send`` dropping the event, as a full or + closed queue, opt-out or a disabled client does. + """ capture = client.capture - raised: List[str] = [] + failed: List[str] = [] - def maybe_raise(name: str, **kwargs: Any) -> Optional[str]: - if name == event and not raised: - raised.append(name) - raise RuntimeError("capture failed") + def maybe_fail(name: str, **kwargs: Any) -> Optional[str]: + if name == event and not failed: + failed.append(name) + if failure == "raise": + raise RuntimeError("capture failed") + return None return capture(name, **kwargs) - return mock.patch.object(client, "capture", side_effect=maybe_raise) + return mock.patch.object(client, "capture", side_effect=maybe_fail) def _sent(client: Client, path: str, reads: int = 2) -> List[str]: @@ -1456,40 +1463,79 @@ def record(msg: Dict[str, Any], *args: Any, **kwargs: Any) -> Optional[str]: @pytest.mark.parametrize("path", ["get_feature_flag", "snapshot.get_flag"]) @pytest.mark.parametrize("event", [FLAG_CALLED, EXPOSURE]) -def test_a_dropped_capture_keeps_no_dedupe_key(event, path): +def test_an_event_dropped_by_before_send_keeps_its_dedupe_key(event, path): client = Client(FAKE_TEST_API_KEY, send=False, before_send=_dropping(event)) try: sent = _sent(client, path) finally: client.shutdown() - # The other event of the first read is still sent, and the dropped one is sent - # again by the second read, once. - assert sorted(sent) == sorted([FLAG_CALLED, EXPOSURE]) + # The application dropped the event, so the second read does not send it again. + # The other event of the first read is still sent, once. + assert sent == [EXPOSURE if event == FLAG_CALLED else FLAG_CALLED] + + +async def test_async_an_event_dropped_by_before_send_keeps_its_dedupe_key(): + seen: List[str] = [] + batches: List[Any] = [] + + def before_send(message: Dict[str, Any]) -> Optional[Dict[str, Any]]: + seen.append(message["event"]) + return None if message["event"] == EXPOSURE else message + + async def batch_post(*args: Any, **kwargs: Any) -> None: + batches.append(kwargs["batch"]) + + with ( + mock.patch( + "posthog.async_client._async_flags", + new=mock.AsyncMock(return_value=_response(_split())), + ), + mock.patch("posthog._async_consumer.async_batch_post", side_effect=batch_post), + ): + client = AsyncPosthog("project-key", before_send=before_send, flush_interval=30) + for _ in range(2): + snapshot = await client.evaluate_flags(DISTINCT_ID) + assert snapshot.get_flag(FLAG) == "annual" + await client.flush(timeout_seconds=1) + await client.shutdown() + + # before_send saw each event once: the dropped exposure kept its key, so the + # second read sent neither event again. + assert seen == [FLAG_CALLED, EXPOSURE] + sent = [event["event"] for batch in batches for event in batch] + assert sent == [FLAG_CALLED] @pytest.mark.parametrize("path", ["get_feature_flag", "snapshot.get_flag"]) @pytest.mark.parametrize("event", [FLAG_CALLED, EXPOSURE]) -def test_a_raising_capture_keeps_no_dedupe_key(event, path, client): - with _raising_once(client, event): +@pytest.mark.parametrize("failure", ["raise", "unqueued"]) +def test_a_failed_capture_keeps_no_dedupe_key(failure, event, path, client): + with _failing_once(client, event, failure): sent = _sent(client, path) + # The other event of the first read is still sent, and the failed one is sent + # by the second read, once. assert sorted(sent) == sorted([FLAG_CALLED, EXPOSURE]) -def test_a_dropped_config_version_1_call_is_deduped_as_before(): - client = Client(FAKE_TEST_API_KEY, send=False, before_send=_dropping(FLAG_CALLED)) - recording, events = _record_events(client) +def test_an_unqueued_config_version_1_call_is_deduped_as_before(client): record = _record("condition_match", "annual", config_version=1) - try: - with _serving(_response(record)), recording: - _read(client, "get_feature_flag") - _read(client, "get_feature_flag") - finally: - client.shutdown() + calls: List[str] = [] + + def unqueued(name: str, **kwargs: Any) -> None: + calls.append(name) + return None + + with ( + _serving(_response(record)), + mock.patch.object(client, "capture", side_effect=unqueued), + ): + _read(client, "get_feature_flag") + _read(client, "get_feature_flag") # Config version 1 dedupe is unchanged: the attempt takes the key. - assert [event.name for event in events] == [FLAG_CALLED] + assert calls == [FLAG_CALLED] def test_a_flags_reload_does_not_repeat_an_exposure(client): From 9cefe7e7bf1372c25d2fbb886a8fdf2d6ea9a878 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Thu, 5 Nov 2026 10:47:01 +0100 Subject: [PATCH 34/34] test(flags): vendor harness 1.28.0 and schema-check drop-the-pair calls Copy the feature flag rules v2 contract files from harness release 1.28.0 (contract 9.0.0). Its called-context schema accepts a config version 2 $feature_flag_called that drops a broken rule pair, or that has a missing or unknown reason code, so the tests for those calls now validate against it, and the skipped reason-code test is folded into the one that checks the attribution. Reword the changeset and the public docstrings that describe the exposure to match the merged spec. Co-Authored-By: Claude Opus 5.5 --- .sampo/changesets/experiment-exposure.md | 2 +- posthog/client.py | 12 +- posthog/feature_flag_evaluations.py | 8 +- .../feature_flag_rules_v2/SOURCE.json | 10 +- .../feature_flag_called_context.schema.json | 215 +++++++++++++++++- .../feature_flag_rules_v2/readers.json | 8 +- posthog/test/test_experiment_exposure.py | 25 +- posthog/test/test_flags_v4_response.py | 4 +- 8 files changed, 238 insertions(+), 46 deletions(-) diff --git a/.sampo/changesets/experiment-exposure.md b/.sampo/changesets/experiment-exposure.md index 001d475a3..622898ef0 100644 --- a/.sampo/changesets/experiment-exposure.md +++ b/.sampo/changesets/experiment-exposure.md @@ -2,4 +2,4 @@ pypi/posthog: minor --- -Send `$experiment_exposure` when a remotely evaluated config version 2 flag assigns an experiment variant. The SDK sends it together with `$feature_flag_called`, from `get_feature_flag()`, `feature_enabled()`, `get_feature_flag_result()`, and the `is_enabled()` and `get_flag()` reads of an `evaluate_flags()` snapshot. It carries the flag key, the same `$feature_flag_response` as its `$feature_flag_called`, the variant key and rule ID, `locally_evaluated: false`, and the flag ID, version, request ID and evaluation time when the response has them. It is sent once per distinct ID, groups, flag, rule and variant, whatever value or request it came from. `$feature_flag_called` for a config version 2 flag gets `$feature_flag_config_version` and `$feature_flag_reason_code`, which replaces the human-readable `$feature_flag_reason`. A targeting match, rollout miss, paused experiment or holdout adds the `$feature_flag_rule_id` and `$feature_flag_rule_type` it names, and a complete experiment split adds these and `$feature_flag_variant`. The rule ID and rule type are sent together, and only with a rule type the reason allows: `targeted_release` or `percentage_rollout` for a targeting match, `percentage_rollout` or `experiment` for a rollout miss, and `experiment` for a paused experiment, a holdout or a split. A record with no rule ID, no rule type or another rule type, such as a targeting match that names an experiment rule, sends neither and no exposure. Other outcomes, including failed flags, add no rule properties. Minimal `$feature_flag_called` events keep these properties, and their dedupe includes them, so equal-valued arms and successive experiments of one flag are each reported. A split that lacks its rule type, rule ID or variant key sends no exposure and reports no rule or variant. A rule ID counts only in the hyphenated UUID form; any other string counts as missing. An empty variant key is present, so a split with one sends the exposure. A config version 2 flag with an unknown or missing reason code still reports its config version, and its code when it has one, with no rule properties and no exposure. Super properties cannot add, remove or change the attribution of config version 2 events. A config version 2 event whose capture raises or cannot be queued (a full or closed queue, opt-out or a disabled client) keeps no dedupe entry, so a later read sends it, and one event failing does not stop the other. An event that `before_send` drops keeps its dedupe entry, so later reads do not offer it again. A flags reload does not reset exposure dedupe. A `get_feature_flag_payload()` lookup with `match_value` reports the caller's value, so it sends no exposure. `send_feature_flag_events=False` suppresses both events. Events of config version 1 flags, flags from servers without typed values and locally evaluated flags are unchanged, including their `$feature_flag_reason`, and `$feature_flag_response` is unchanged on every event. +Send `$experiment_exposure` for config version 2 experiment splits, and add rule attribution to `$feature_flag_called` for config version 2 flags. A read that sends `$feature_flag_called` now also sends `$experiment_exposure` when the flag was evaluated remotely and its config version 2 record is a complete experiment split: reason code `experiment_split`, rule type `experiment`, a rule ID and a variant key. This applies to `get_feature_flag()`, `feature_enabled()`, `get_feature_flag_result()`, and the `is_enabled()` and `get_flag()` reads of an `evaluate_flags()` snapshot. The exposure carries `$feature_flag`, the same `$feature_flag_response` as its `$feature_flag_called`, `$feature_flag_variant`, `$feature_flag_rule_id` and `locally_evaluated: false`, plus the flag ID, flag version, request ID and evaluation time when the response has them. It does not carry the config version, reason code or rule type, which stay on `$feature_flag_called`. Minimal-event mode does not apply to the exposure. The SDK sends one exposure per distinct ID, groups, flag, rule and variant. The value, request ID and evaluation time are not part of this dedupe, a flags reload does not reset it, and it is separate from the `$feature_flag_called` dedupe. No other outcome sends an exposure: not a targeting match, rollout miss, paused experiment, holdout or failed record, and not a config version 1 flag, a flag from a server without typed values or a locally evaluated flag. A config version 2 `$feature_flag_called` carries `$feature_flag_config_version` 2 and `$feature_flag_reason_code`, which replaces `$feature_flag_reason`. A complete experiment split also adds `$feature_flag_rule_id`, `$feature_flag_rule_type` and `$feature_flag_variant`. A targeting match (rule type `targeted_release` or `percentage_rollout`), a rollout miss (`percentage_rollout` or `experiment`), a paused experiment or a holdout (`experiment`) adds `$feature_flag_rule_id` and `$feature_flag_rule_type`. Other reasons and failed records add no rule properties. The rule ID and rule type are sent as a pair or not at all: when a record has only one of them, a rule type its reason does not allow, or a rule ID that is not a hyphenated UUID, the event drops both and keeps the config version and reason code. A split that is missing its rule type, rule ID or variant key sends no exposure and no rule properties. A reason code the SDK does not know is sent unchanged with no rule properties, and a record without a reason code still sends its config version. None of this changes the value a read returns. The `$feature_flag_called` dedupe of a config version 2 flag includes its attribution, so equal-valued arms, successive experiments of one flag and a holdout after a variant are each reported. Minimal `$feature_flag_called` events keep the attribution. The SDK tries the two events separately. If capture of one raises or drops it (a full or closed queue, opt-out or a disabled client), the other is still sent, and the failed event keeps no dedupe entry, so a later read tries it again. An event that `before_send` drops counts as handled: it keeps its dedupe entry, and later reads do not offer it again. `get_feature_flag_payload()` sends no flag events by default; a call with `match_value` reports the caller's value, so it sends no exposure. `send_feature_flag_events=False` suppresses both events. Events of config version 1 flags, flags from servers without typed values and locally evaluated flags do not change, including their `$feature_flag_reason`, and `$feature_flag_response` does not change on any event. diff --git a/posthog/client.py b/posthog/client.py index 47fe23bc9..7fbce7629 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -626,8 +626,9 @@ def _capture_flag_events( Both config version 2 events are attempted independently: a failed capture of one does not stop the other, and keeps no dedupe key, so a later read tries - again. An event that ``before_send`` drops keeps its key. Their attribution is pinned after super properties are merged, so - a super property can neither change it nor add a field the event never carries. + again. An event that ``before_send`` drops keeps its key. Their attribution is + pinned after super properties are merged, so a super property can neither change + it nor add a field the event never carries. Config version 1 events are sent and deduped as before. """ groups_key = ( @@ -4695,9 +4696,10 @@ def get_feature_flag_result( """ Get a FeatureFlagResult object which contains the flag result and payload for a key by evaluating locally or remotely depending on whether local evaluation is enabled and the flag can be locally evaluated. - This also captures the `$feature_flag_called` event unless `send_feature_flag_events` is `False`, - and, when a remotely evaluated config version 2 flag assigns an experiment variant, the - `$experiment_exposure` event. + This also captures the `$feature_flag_called` event unless `send_feature_flag_events` is `False`. + When the flag was evaluated remotely and its config version 2 record is a complete experiment + split (an experiment rule with a rule ID and a variant key), it also captures the + `$experiment_exposure` event, once per distinct ID, groups, flag, rule and variant. Examples: ```python diff --git a/posthog/feature_flag_evaluations.py b/posthog/feature_flag_evaluations.py index cbd68f92f..ee179d4bc 100644 --- a/posthog/feature_flag_evaluations.py +++ b/posthog/feature_flag_evaluations.py @@ -127,9 +127,11 @@ class FeatureFlagEvaluations: from the server in the first place, pass ``flag_keys`` to :meth:`Client.evaluate_flags`. Reading a flag sends ``$feature_flag_called``, deduplicated as :meth:`is_enabled` - describes. When the flag was evaluated remotely and its config version 2 record - assigns an experiment variant, the read also sends ``$experiment_exposure``, once - per assignment. + describes. When the flag was evaluated remotely and its config version 2 record is + a complete experiment split (an experiment rule with a rule ID and a variant key), + the read also sends ``$experiment_exposure``. The SDK sends one exposure per + distinct ID, groups, flag, rule and variant. ``send_feature_flag_events=False`` + suppresses both events. """ def __init__( diff --git a/posthog/test/fixtures/feature_flag_rules_v2/SOURCE.json b/posthog/test/fixtures/feature_flag_rules_v2/SOURCE.json index f7b015d0a..d9b4518a7 100644 --- a/posthog/test/fixtures/feature_flag_rules_v2/SOURCE.json +++ b/posthog/test/fixtures/feature_flag_rules_v2/SOURCE.json @@ -1,14 +1,14 @@ { "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.27.0", - "revision": "75fb00092c6045daef60c30acc10ac83afefbfe9", + "release": "1.28.0", + "revision": "ffa6cc38dbc4efa64b474d2d81a6c6abad4cd07a", "contract_directory": "contracts/feature_flag_rules_v2", - "contract_version": "8.0.0", + "contract_version": "9.0.0", "files": { "readers.json": { "source_path": "fixtures/wire/readers.json", - "sha256": "fd8edd89f827b732c4659ce7246eab534b0106b96a465a4a2d98cfa0a17d7827" + "sha256": "e75abb9bf6656989a147dda4c6e5232e7b544b657d7d540164dce65688641501" }, "responses.json": { "source_path": "fixtures/wire/responses.json", @@ -24,7 +24,7 @@ }, "feature_flag_called_context.schema.json": { "source_path": "schemas/feature_flag_called_context.schema.json", - "sha256": "816acfe33391d38471a4530ddac8b147c2bdc2e3c4ddbd8f384368faf57ee3b6" + "sha256": "8d151f067bfcd2c69a9ce0193f0666a4f9af58276a7ff724787518e04480fcc1" }, "experiment_exposure_properties.schema.json": { "source_path": "schemas/experiment_exposure_properties.schema.json", diff --git a/posthog/test/fixtures/feature_flag_rules_v2/feature_flag_called_context.schema.json b/posthog/test/fixtures/feature_flag_rules_v2/feature_flag_called_context.schema.json index e55fd2fa2..7bad83d87 100644 --- a/posthog/test/fixtures/feature_flag_rules_v2/feature_flag_called_context.schema.json +++ b/posthog/test/fixtures/feature_flag_rules_v2/feature_flag_called_context.schema.json @@ -56,7 +56,7 @@ "const": true } }, - "description": "Closed projection of the flag-access context properties. Other existing capture fields are outside this projection. Complete v2 producer context follows the terminal outcome; tolerant-reader diagnostics may omit an incomplete attribution tuple. A config version 2 call carries $feature_flag_reason_code and never $feature_flag_reason, the reason description that only version 1 calls carry.", + "description": "Closed projection of the flag-access context properties. Other existing capture fields are outside this projection. Complete v2 producer context follows the terminal outcome. A tolerant reader drops the whole attribution of a record that breaks its reason's rows (a rule ID without its rule type or the reverse, a rule type the reason does not allow, or a malformed split): for every reason that names a rule, a call may keep the config version and reason code and omit all rule, variant and experiment attribution, while a partly populated or mismatched tuple is rejected. A config version 2 call whose reason code is missing or not registered keeps the raw code, if any, and carries no attribution; a registered code only matches its own reason's branches. A config version 2 call never carries $feature_flag_reason, the reason description that only version 1 calls carry.", "allOf": [ { "if": { @@ -70,9 +70,6 @@ } }, "then": { - "required": [ - "$feature_flag_reason_code" - ], "not": { "anyOf": [ { @@ -98,6 +95,7 @@ } }, "required": [ + "$feature_flag_reason_code", "$feature_flag_rule_type", "$feature_flag_rule_id" ], @@ -126,6 +124,7 @@ } }, "required": [ + "$feature_flag_reason_code", "$feature_flag_rule_type", "$feature_flag_rule_id" ], @@ -154,6 +153,7 @@ } }, "required": [ + "$feature_flag_reason_code", "$feature_flag_rule_type", "$feature_flag_rule_id", "$feature_flag_variant" @@ -178,6 +178,7 @@ } }, "required": [ + "$feature_flag_reason_code", "$feature_flag_rule_type", "$feature_flag_rule_id" ], @@ -206,6 +207,7 @@ } }, "required": [ + "$feature_flag_reason_code", "$feature_flag_rule_type", "$feature_flag_rule_id" ], @@ -234,6 +236,7 @@ } }, "required": [ + "$feature_flag_reason_code", "$feature_flag_rule_type", "$feature_flag_rule_id" ], @@ -262,6 +265,7 @@ } }, "required": [ + "$feature_flag_reason_code", "$feature_flag_rule_type", "$feature_flag_rule_id" ], @@ -286,6 +290,9 @@ "const": "no_rule_match" } }, + "required": [ + "$feature_flag_reason_code" + ], "not": { "anyOf": [ { @@ -317,6 +324,9 @@ "const": "missing_group_key" } }, + "required": [ + "$feature_flag_reason_code" + ], "not": { "anyOf": [ { @@ -348,6 +358,9 @@ "const": "dependency_error" } }, + "required": [ + "$feature_flag_reason_code" + ], "not": { "anyOf": [ { @@ -379,6 +392,9 @@ "const": "error" } }, + "required": [ + "$feature_flag_reason_code" + ], "not": { "anyOf": [ { @@ -405,12 +421,14 @@ } }, { - "description": "A local-only outcome with no matrix row: the server never returns flag_disabled, and the call carries no rule or experiment attribution.", "properties": { "$feature_flag_reason_code": { - "const": "flag_disabled" + "const": "targeting_match" } }, + "required": [ + "$feature_flag_reason_code" + ], "not": { "anyOf": [ { @@ -437,12 +455,195 @@ } }, { - "description": "A tolerant reader may emit a diagnostic call for a malformed split after omitting all rule/experiment attribution. The config marker and reason remain available for diagnostics.", "properties": { "$feature_flag_reason_code": { "const": "experiment_split" } }, + "required": [ + "$feature_flag_reason_code" + ], + "not": { + "anyOf": [ + { + "required": [ + "$feature_flag_rule_type" + ] + }, + { + "required": [ + "$feature_flag_rule_id" + ] + }, + { + "required": [ + "$feature_flag_experiment_id" + ] + }, + { + "required": [ + "$feature_flag_variant" + ] + } + ] + } + }, + { + "properties": { + "$feature_flag_reason_code": { + "const": "rollout_miss" + } + }, + "required": [ + "$feature_flag_reason_code" + ], + "not": { + "anyOf": [ + { + "required": [ + "$feature_flag_rule_type" + ] + }, + { + "required": [ + "$feature_flag_rule_id" + ] + }, + { + "required": [ + "$feature_flag_experiment_id" + ] + }, + { + "required": [ + "$feature_flag_variant" + ] + } + ] + } + }, + { + "properties": { + "$feature_flag_reason_code": { + "const": "experiment_paused" + } + }, + "required": [ + "$feature_flag_reason_code" + ], + "not": { + "anyOf": [ + { + "required": [ + "$feature_flag_rule_type" + ] + }, + { + "required": [ + "$feature_flag_rule_id" + ] + }, + { + "required": [ + "$feature_flag_experiment_id" + ] + }, + { + "required": [ + "$feature_flag_variant" + ] + } + ] + } + }, + { + "properties": { + "$feature_flag_reason_code": { + "const": "holdout" + } + }, + "required": [ + "$feature_flag_reason_code" + ], + "not": { + "anyOf": [ + { + "required": [ + "$feature_flag_rule_type" + ] + }, + { + "required": [ + "$feature_flag_rule_id" + ] + }, + { + "required": [ + "$feature_flag_experiment_id" + ] + }, + { + "required": [ + "$feature_flag_variant" + ] + } + ] + } + }, + { + "properties": { + "$feature_flag_reason_code": { + "not": { + "enum": [ + "targeting_match", + "experiment_split", + "rollout_miss", + "experiment_paused", + "holdout", + "no_rule_match", + "missing_group_key", + "dependency_error", + "error", + "flag_disabled" + ] + } + } + }, + "not": { + "anyOf": [ + { + "required": [ + "$feature_flag_rule_type" + ] + }, + { + "required": [ + "$feature_flag_rule_id" + ] + }, + { + "required": [ + "$feature_flag_experiment_id" + ] + }, + { + "required": [ + "$feature_flag_variant" + ] + } + ] + } + }, + { + "description": "A local-only outcome with no matrix row: the server never returns flag_disabled, and the call carries no rule or experiment attribution.", + "properties": { + "$feature_flag_reason_code": { + "const": "flag_disabled" + } + }, + "required": [ + "$feature_flag_reason_code" + ], "not": { "anyOf": [ { diff --git a/posthog/test/fixtures/feature_flag_rules_v2/readers.json b/posthog/test/fixtures/feature_flag_rules_v2/readers.json index 7ed8c252b..add97e172 100644 --- a/posthog/test/fixtures/feature_flag_rules_v2/readers.json +++ b/posthog/test/fixtures/feature_flag_rules_v2/readers.json @@ -992,7 +992,10 @@ "value": "compact", "config_version": 1, "experiment_exposure": false, - "error_code": null + "error_code": null, + "legacy_payload": { + "columns": 2 + } }, "producer_expected": "valid" }, @@ -1018,7 +1021,8 @@ "value": true, "config_version": 1, "experiment_exposure": false, - "error_code": "GENERAL" + "error_code": "GENERAL", + "legacy_payload": null }, "producer_expected": "valid" }, diff --git a/posthog/test/test_experiment_exposure.py b/posthog/test/test_experiment_exposure.py index b69b2bbb8..5ad3db960 100644 --- a/posthog/test/test_experiment_exposure.py +++ b/posthog/test/test_experiment_exposure.py @@ -598,9 +598,7 @@ def test_rule_matches_misses_pauses_and_holdouts_name_their_rule(code, rule_type # The rule ID and rule type are sent together, with a rule type the reason allows, or -# not at all. Contract 8.0.0 accepts such a call without its rule pair only for a -# malformed split, so these calls are not validated against the vendored schema -# until a harness release that accepts them is copied. +# not at all. A call that drops a broken pair keeps its config version and reason code. BROKEN_RULE_PAIR_CASES = [ # Half pairs. ("targeting_match", {"rule_id": RULE}), @@ -638,6 +636,7 @@ def test_a_half_or_disallowed_rule_pair_is_not_sent(code, fields, path): "$feature_flag_reason_code": code, } assert _named(events, EXPOSURE) == [] + FLAG_CALLED_SCHEMA.validate(_projection(FLAG_CALLED_SCHEMA, called.properties)) @pytest.mark.parametrize("path", list(SYNC_READS)) @@ -661,6 +660,7 @@ def test_a_targeting_match_that_names_an_experiment_rule_sends_no_exposure(path) "$feature_flag_reason_code": "targeting_match", } assert _named(events, EXPOSURE) == [] + FLAG_CALLED_SCHEMA.validate(_projection(FLAG_CALLED_SCHEMA, called.properties)) def test_an_older_server_record_reports_no_attribution(): @@ -1343,24 +1343,6 @@ def test_an_unknown_or_missing_reason_code_still_reports_config_version_2(code, assert _attribution(called.properties) == expected assert "$feature_flag_reason" not in called.properties assert _named(events, EXPOSURE) == [] - - -@pytest.mark.skip( - reason="Contract 8.0.0 requires a registered reason code on a config version 2" - " call. Turn this on once a harness release with contract 9.0.0" - " (PostHog/posthog-sdk-test-harness#101) is vendored." -) -@pytest.mark.parametrize("code", ["future_reason", None], ids=["unknown", "missing"]) -def test_a_call_with_an_unknown_or_missing_reason_code_matches_the_contract(code): - record = _split() - if code is None: - del record["reason"] - else: - record["reason"]["code"] = code - - events = _read_events("get_feature_flag_result", _response(record)) - - [called] = _named(events, FLAG_CALLED) FLAG_CALLED_SCHEMA.validate(_projection(FLAG_CALLED_SCHEMA, called.properties)) @@ -1382,6 +1364,7 @@ def test_a_rule_id_that_is_not_a_uuid_counts_as_missing(rule_id, path): "$feature_flag_reason_code": record["reason"]["code"], } assert _named(events, EXPOSURE) == [] + FLAG_CALLED_SCHEMA.validate(_projection(FLAG_CALLED_SCHEMA, called.properties)) def test_an_upper_case_uuid_rule_id_is_kept(): diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py index 1b029e539..c48a2936f 100644 --- a/posthog/test/test_flags_v4_response.py +++ b/posthog/test/test_flags_v4_response.py @@ -83,8 +83,8 @@ def _evaluate(client: Client, response: Dict[str, Any]) -> FeatureFlagEvaluation def test_vendored_fixtures_match_the_recorded_release_digests(): source = _load("SOURCE.json") - assert source["release"] == "1.27.0" - assert source["contract_version"] == "8.0.0" + assert source["release"] == "1.28.0" + assert source["contract_version"] == "9.0.0" for name, entry in source["files"].items(): digest = hashlib.sha256((FIXTURES / name).read_bytes()).hexdigest() assert digest == entry["sha256"], name