diff --git a/.sampo/changesets/flags-v4-response.md b/.sampo/changesets/flags-v4-response.md new file mode 100644 index 000000000..976dc5397 --- /dev/null +++ b/.sampo/changesets/flags-v4-response.md @@ -0,0 +1,5 @@ +--- +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. diff --git a/posthog/__init__.py b/posthog/__init__.py index 2e870403c..eda01933c 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -81,6 +81,9 @@ BeforeSendCallback as BeforeSendCallback, FeatureFlag as FeatureFlag, FeatureFlagEvaluationRuntime as FeatureFlagEvaluationRuntime, + FlagEvaluationDetails as FlagEvaluationDetails, + FlagEvaluationErrorCode as FlagEvaluationErrorCode, + FlagEvaluationReason as FlagEvaluationReason, FlagValue as FlagValue, FlagsAndPayloads as FlagsAndPayloads, ) diff --git a/posthog/_async_request.py b/posthog/_async_request.py index 00120971a..50dfe829c 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=4", 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 186bff727..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( @@ -968,6 +981,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 bff6def5d..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; @@ -4712,6 +4722,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..c9a6f808c 100644 --- a/posthog/feature_flag_evaluations.py +++ b/posthog/feature_flag_evaluations.py @@ -8,7 +8,13 @@ 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, + _resolve_flag_value, +) @dataclass(frozen=True) @@ -26,6 +32,11 @@ 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 + # Whether a locally evaluated flag is off because its definition is inactive. + inactive: bool = False @dataclass @@ -123,6 +134,132 @@ def get_flag(self, key: str) -> Optional[FlagValue]: return False return flag.variant if flag.variant is not None else True + def get_boolean_value(self, key: str, default_value: bool) -> bool: + """Return the flag's boolean value, or ``default_value``. + + Like :meth:`get_flag`, fires ``$feature_flag_called`` on first access. See + :meth:`get_boolean_details` for when the default is returned. + """ + return self.get_boolean_details(key, default_value).value + + def get_boolean_details( + self, key: str, default_value: bool + ) -> FlagEvaluationDetails[bool]: + """Return the flag's boolean value with its evaluation details. + + The value is never coerced: a variant string is not ``True``. + ``default_value`` is returned when the flag is not in the evaluation, + failed, has no value, or has a value of another type; ``error_code`` says + which, except for a flag without a value. A flag whose value is ``False`` + returns ``False``. Fires ``$feature_flag_called`` on first access, like + :meth:`get_flag`. + """ + return self._get_typed_details(key, default_value, "boolean") + + def get_string_value(self, key: str, default_value: str) -> str: + """Return the flag's string value, or ``default_value``. + + Like :meth:`get_flag`, fires ``$feature_flag_called`` on first access. See + :meth:`get_string_details` for when the default is returned. + """ + return self.get_string_details(key, default_value).value + + def get_string_details( + self, key: str, default_value: str + ) -> FlagEvaluationDetails[str]: + """Return the flag's string value with its evaluation details. + + The value is never coerced. ``default_value`` is returned when the flag is + not in the evaluation, failed, has no value, or has a value of another + type; ``error_code`` says which, except for a flag without a value or with + the value ``False``. Fires ``$feature_flag_called`` on first access, like + :meth:`get_flag`. + """ + return self._get_typed_details(key, default_value, "string") + + def get_number_value(self, key: str, default_value: float) -> float: + """Return the flag's number value, or ``default_value``. + + An integer value is returned as an ``int``. Like :meth:`get_flag`, fires + ``$feature_flag_called`` on first access. See :meth:`get_number_details` for + when the default is returned. + """ + return self.get_number_details(key, default_value).value + + def get_number_details( + self, key: str, default_value: float + ) -> FlagEvaluationDetails[float]: + """Return the flag's number value with its evaluation details. + + The value is never coerced: a boolean or a numeric string is not a number. + ``default_value`` is returned when the flag is not in the evaluation, + failed, has no value, or has a value of another type; ``error_code`` says + which, except for a flag without a value or with the value ``False``. Fires + ``$feature_flag_called`` on first access, like :meth:`get_flag`. + """ + return self._get_typed_details(key, default_value, "number") + + def get_integer_value(self, key: str, default_value: int) -> int: + """Return the flag's number value as an ``int``, or ``default_value``. + + Like :meth:`get_flag`, fires ``$feature_flag_called`` on first access. See + :meth:`get_integer_details` for when the default is returned. + """ + return self.get_integer_details(key, default_value).value + + def get_integer_details( + self, key: str, default_value: int + ) -> FlagEvaluationDetails[int]: + """Return the flag's number value as an ``int`` with its evaluation details. + + Like :meth:`get_number_details`, but a number with a fractional part, such + as ``0.25``, is a ``TYPE_MISMATCH``. An integral number such as ``2.0`` is + returned as ``2``. + """ + return self._get_typed_details(key, default_value, "integer") + + def get_float_value(self, key: str, default_value: float) -> float: + """Return the flag's number value as a ``float``, or ``default_value``. + + Like :meth:`get_flag`, fires ``$feature_flag_called`` on first access. See + :meth:`get_float_details` for when the default is returned. + """ + return self.get_float_details(key, default_value).value + + def get_float_details( + self, key: str, default_value: float + ) -> FlagEvaluationDetails[float]: + """Return the flag's number value as a ``float`` with its evaluation details. + + Like :meth:`get_number_details`, but an integer value is returned as a + ``float``. + """ + return self._get_typed_details(key, default_value, "float") + + def get_object_value( + self, key: str, default_value: Dict[str, Any] + ) -> Dict[str, Any]: + """Return the flag's object value, or ``default_value``. + + Like :meth:`get_flag`, fires ``$feature_flag_called`` on first access. See + :meth:`get_object_details` for when the default is returned. + """ + return self.get_object_details(key, default_value).value + + def get_object_details( + self, key: str, default_value: Dict[str, Any] + ) -> FlagEvaluationDetails[Dict[str, Any]]: + """Return the flag's object value with its evaluation details. + + The value is never coerced: a payload is not an object value. The returned + object is a copy. ``default_value`` is returned when the flag is not in the + evaluation, failed, has no value, or has a value of another type; + ``error_code`` says which, except for a flag without a value or with the + value ``False``. Fires ``$feature_flag_called`` on first access, like + :meth:`get_flag`. + """ + return self._get_typed_details(key, default_value, "object") + def get_flag_payload(self, key: str) -> Optional[Any]: """Return the payload associated with a flag. @@ -132,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. @@ -215,6 +352,36 @@ 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 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/request.py b/posthog/request.py index 9b7b1b1b2..cfc9910de 100644 --- a/posthog/request.py +++ b/posthog/request.py @@ -330,7 +330,7 @@ def flags( res = post( api_key, host, - "/flags/?v=2", + "/flags/?v=4", 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..df7f1c7e0 --- /dev/null +++ b/posthog/test/fixtures/feature_flag_rules_v2/SOURCE.json @@ -0,0 +1,26 @@ +{ + "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", + "contract_directory": "contracts/feature_flag_rules_v2", + "contract_version": "7.0.0", + "files": { + "readers.json": { + "source_path": "fixtures/wire/readers.json", + "sha256": "fd8edd89f827b732c4659ce7246eab534b0106b96a465a4a2d98cfa0a17d7827" + }, + "responses.json": { + "source_path": "fixtures/wire/responses.json", + "sha256": "39aa62e340d66903e2fe86da226e622979c7dbc2efbf2b32afd040248a707c61" + }, + "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..7ed8c252b --- /dev/null +++ b/posthog/test/fixtures/feature_flag_rules_v2/readers.json @@ -0,0 +1,1078 @@ +{ + "schema": "schemas/flags_response_v4.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", + "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" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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", + "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_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_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", + "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.v4_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_v4", + "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/id": 201.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", + "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..1d1c60969 --- /dev/null +++ b/posthog/test/fixtures/feature_flag_rules_v2/responses.json @@ -0,0 +1,1151 @@ +{ + "schema": "schemas/flags_response_v4.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", + "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" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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_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_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_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_flag_without_experiment", + "template": "experiment_split", + "set": { + "/flags/garden-layout/metadata/has_experiment": false + }, + "expected": "valid" + }, + { + "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/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_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_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_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_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_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_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_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_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_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.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", + "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", + "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", + "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_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..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=2" + "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 d95da855a..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=2",) + 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_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_v4_response.py b/posthog/test/test_flags_v4_response.py new file mode 100644 index 000000000..ee5357097 --- /dev/null +++ b/posthog/test/test_flags_v4_response.py @@ -0,0 +1,1164 @@ +"""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 +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, + FlagEvaluationReason, + 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.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" + 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 + + +@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"] + + +@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(): + # 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 + + +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, + "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": { + 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"}, + } + } + ) + 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): + 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.", + ) + + +@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.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], variant_key=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.variant_key is None + + +@pytest.mark.parametrize( + "field, value", + [ + ("experiment_id", 301), + ("holdout_id", 401), + ("holdout_id", "401"), + ], +) +def test_experiment_and_holdout_ids_in_the_metadata_are_ignored(field, value): + # 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"][ + "garden-layout" + ] + + assert flag.error_code is None + assert flag.value == "compact" + assert isinstance(flag.metadata, FlagMetadata) + assert not hasattr(flag.metadata, field) + + +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", + "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.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 v4 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 _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: + 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"] + v4_flags = ( + {} + if outcome["omitted"] + else {outcome["key"]: _v4_record_for_v1_outcome(outcome)} + ) + v4_response = { + "errorsWhileComputingFlags": v2_response["errorsWhileComputingFlags"], + "flags": v4_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(v4_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} + v4_metadata = {**metadata, "config_version": 2, "payload": None} + if rule: + v4_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), + }, + } + v4_record = { + "key": "typed-flag", + "value": expected["value"], + "reason": reason, + "metadata": v4_metadata, + } + 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, v4_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, v4_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 v4-shaped response for +# the same flags give identical results. + +PARITY_FLAGS = [ + # 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"), + ("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") + v4_outputs = _legacy_outputs("v4") + + assert v4_outputs == v2_outputs + # Spot-check the legacy rendering itself. + assert v4_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 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): + 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" + # 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)), + ): + 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..176f0c8e5 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -1,11 +1,26 @@ +import copy import json import logging -from dataclasses import dataclass +import math +from dataclasses import dataclass, field from enum import Enum -from typing import Any, Callable, List, Optional, TypedDict, Union, cast +from typing import ( + Any, + Callable, + Dict, + Generic, + List, + Optional, + TypedDict, + TypeVar, + Union, + cast, +) FlagValue = Union[bool, str] +_T = TypeVar("_T") + def _reject_json_constant(value: str) -> None: raise ValueError("Invalid JSON constant") @@ -137,11 +152,20 @@ 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=4`` 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. + variant_key: Key of the assigned variant, when one was assigned. + forced_variant: Whether a condition-level variant override produced the + result. """ id: int @@ -149,9 +173,19 @@ 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 + variant_key: Optional[str] = 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=4``, 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 +197,93 @@ 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.""" + + 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``.""" + + 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=4`` 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 +291,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=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``. + """ + 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 +328,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 +357,417 @@ 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=4`` 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=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, + 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 + ), + variant_key=_read_typed( + raw_metadata.get("variant_key"), "metadata.variant_key", str + ), + forced_variant=_read_typed( + 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, + 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, + ) + + +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, + ) + + +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.""" @@ -340,7 +885,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 v4 response from the flags API endpoint. Returns: A FlagsResponse containing feature flags and their details. @@ -353,6 +898,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 91fb0c168..9b4daf367 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -21,6 +21,9 @@ alias posthog.FeatureFlagEvaluations -> posthog.feature_flag_evaluations.Feature alias posthog.FeatureFlagResult -> posthog.types.FeatureFlagResult alias posthog.FlagDefinitionCacheData -> posthog.flag_definition_cache.FlagDefinitionCacheData alias posthog.FlagDefinitionCacheProvider -> posthog.flag_definition_cache.FlagDefinitionCacheProvider +alias posthog.FlagEvaluationDetails -> posthog.types.FlagEvaluationDetails +alias posthog.FlagEvaluationErrorCode -> posthog.types.FlagEvaluationErrorCode +alias posthog.FlagEvaluationReason -> posthog.types.FlagEvaluationReason alias posthog.FlagValue -> posthog.types.FlagValue alias posthog.FlagsAndPayloads -> posthog.types.FlagsAndPayloads alias posthog.ID_TYPES -> posthog.args.ID_TYPES @@ -319,6 +322,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 @@ -1120,9 +1125,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' @@ -1138,11 +1147,35 @@ attribute posthog.types.FeatureFlagResult.key: str attribute posthog.types.FeatureFlagResult.payload: Optional[Any] attribute posthog.types.FeatureFlagResult.reason: Optional[str] attribute posthog.types.FeatureFlagResult.variant: Optional[str] +attribute posthog.types.FlagEvaluationDetails.error_code: Optional[FlagEvaluationErrorCode] = None +attribute posthog.types.FlagEvaluationDetails.error_message: Optional[str] = None +attribute posthog.types.FlagEvaluationDetails.flag_metadata: Dict[str, FlagMetadataValue] = field(default_factory=dict) +attribute posthog.types.FlagEvaluationDetails.key: str +attribute posthog.types.FlagEvaluationDetails.reason: FlagEvaluationReason +attribute posthog.types.FlagEvaluationDetails.value: _T +attribute posthog.types.FlagEvaluationDetails.variant: Optional[str] = None +attribute posthog.types.FlagEvaluationErrorCode.FLAG_NOT_FOUND = 'FLAG_NOT_FOUND' +attribute posthog.types.FlagEvaluationErrorCode.GENERAL = 'GENERAL' +attribute posthog.types.FlagEvaluationErrorCode.INVALID_CONTEXT = 'INVALID_CONTEXT' +attribute posthog.types.FlagEvaluationErrorCode.PARSE_ERROR = 'PARSE_ERROR' +attribute posthog.types.FlagEvaluationErrorCode.TYPE_MISMATCH = 'TYPE_MISMATCH' +attribute posthog.types.FlagEvaluationReason.DEFAULT = 'DEFAULT' +attribute posthog.types.FlagEvaluationReason.DISABLED = 'DISABLED' +attribute posthog.types.FlagEvaluationReason.ERROR = 'ERROR' +attribute posthog.types.FlagEvaluationReason.SPLIT = 'SPLIT' +attribute posthog.types.FlagEvaluationReason.TARGETING_MATCH = 'TARGETING_MATCH' +attribute posthog.types.FlagEvaluationReason.UNKNOWN = 'UNKNOWN' +attribute posthog.types.FlagMetadata.config_version: Optional[int] = None attribute posthog.types.FlagMetadata.description: str +attribute posthog.types.FlagMetadata.forced_variant: Optional[bool] = None attribute posthog.types.FlagMetadata.has_experiment: Optional[bool] = None attribute posthog.types.FlagMetadata.id: 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.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 @@ -1322,11 +1355,14 @@ class posthog.request.GetResponse(data: Any, etag: Optional[str] = None, not_mod class posthog.request.HTTPAdapterWithSocketOptions(*args, socket_options: Optional[SocketOptions] = None, **kwargs) class posthog.request.QuotaLimitError class posthog.tracing.span.Span -class posthog.types.FeatureFlag(key: str, enabled: bool, variant: Optional[str], reason: Optional[FlagReason], metadata: Union[FlagMetadata, LegacyFlagMetadata]) +class posthog.types.FeatureFlag(key: str, enabled: bool, variant: Optional[str], reason: Optional[FlagReason], metadata: Union[FlagMetadata, LegacyFlagMetadata], value: Any = _DERIVED_VALUE, failed: bool = False, error_code: Optional[FlagEvaluationErrorCode] = None, error_message: Optional[str] = None) class posthog.types.FeatureFlagError class posthog.types.FeatureFlagEvaluationRuntime class posthog.types.FeatureFlagResult(key: str, enabled: bool, variant: Optional[str], payload: Optional[Any], reason: Optional[str]) -class posthog.types.FlagMetadata(id: int, payload: Optional[str], version: int, description: str, has_experiment: Optional[bool] = None) +class posthog.types.FlagEvaluationDetails(key: str, value: _T, reason: FlagEvaluationReason, variant: Optional[str] = None, error_code: Optional[FlagEvaluationErrorCode] = None, error_message: Optional[str] = None, flag_metadata: Dict[str, FlagMetadataValue] = dict()) +class posthog.types.FlagEvaluationErrorCode +class posthog.types.FlagEvaluationReason +class posthog.types.FlagMetadata(id: 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 @@ -1790,8 +1826,20 @@ method posthog.exception_utils.AnnotatedValue.substituted_because_contains_sensi method posthog.exception_utils.VariableSizeLimiter.add(size) method posthog.exception_utils.VariableSizeLimiter.can_add(size) method posthog.exception_utils.VariableSizeLimiter.get_remaining_space() +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_boolean_details(key: str, default_value: bool) -> FlagEvaluationDetails[bool] +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_boolean_value(key: str, default_value: bool) -> bool method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_flag(key: str) -> Optional[FlagValue] method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_flag_payload(key: str) -> Optional[Any] +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_float_details(key: str, default_value: float) -> FlagEvaluationDetails[float] +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_float_value(key: str, default_value: float) -> float +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_integer_details(key: str, default_value: int) -> FlagEvaluationDetails[int] +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_integer_value(key: str, default_value: int) -> int +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_number_details(key: str, default_value: float) -> FlagEvaluationDetails[float] +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_number_value(key: str, default_value: float) -> float +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_object_details(key: str, default_value: Dict[str, Any]) -> FlagEvaluationDetails[Dict[str, Any]] +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_object_value(key: str, default_value: Dict[str, Any]) -> Dict[str, Any] +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_string_details(key: str, default_value: str) -> FlagEvaluationDetails[str] +method posthog.feature_flag_evaluations.FeatureFlagEvaluations.get_string_value(key: str, default_value: str) -> str method posthog.feature_flag_evaluations.FeatureFlagEvaluations.is_enabled(key: str, default_value: bool = False) -> bool method posthog.feature_flag_evaluations.FeatureFlagEvaluations.only(keys: List[str]) -> FeatureFlagEvaluations method posthog.feature_flag_evaluations.FeatureFlagEvaluations.only_accessed() -> FeatureFlagEvaluations 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