diff --git a/.github/workflows/sdk-compliance.yml b/.github/workflows/sdk-compliance.yml index b200a0dc1..2df4d111c 100644 --- a/.github/workflows/sdk-compliance.yml +++ b/.github/workflows/sdk-compliance.yml @@ -33,7 +33,7 @@ jobs: with: adapter-dockerfile: "sdk_compliance_adapter/Dockerfile" adapter-context: "." - test-harness-version: "1.1.1" + test-harness-version: "1.23.0" continue-on-error: false report-name: "sdk-compliance-report-v0" @@ -43,6 +43,6 @@ jobs: with: adapter-dockerfile: "sdk_compliance_adapter/Dockerfile.v1" adapter-context: "." - test-harness-version: "1.1.1" + test-harness-version: "1.23.0" continue-on-error: false report-name: "sdk-compliance-report-v1" diff --git a/.sampo/changesets/flags-v4-response.md b/.sampo/changesets/flags-v4-response.md new file mode 100644 index 000000000..4682a45b7 --- /dev/null +++ b/.sampo/changesets/flags-v4-response.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Request `/flags?v=4` and read each flag's typed value, reason and metadata. This release adds no new public API for them yet. Servers that still send the older response keep working, and a flag record with a field of the wrong JSON type fails only that flag. For valid flag records, existing getters, callbacks and `$feature_flag_called` properties return the same values as before: a number or object value reads as `True`, with the value as its payload. Two things can differ: the human-readable reason text of a config version 2 flag is the one the server sends at v4, and a record with a known field of the wrong JSON type (for example a string `reason`) now reads as a failed flag instead of being read anyway or failing the whole response. The SDK logs once per client when a server sends a response without typed values. `FlagMetadata.id` and `FlagMetadata.version` are `None` when a record has none, instead of `0`, so their type is now `Optional[int]`. 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 d1015ebba..323b16b89 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( @@ -970,6 +983,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 3b918b0ba..4c090029b 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -122,6 +122,8 @@ FlagValue, SendFeatureFlagsOptions, UnresolvedFlagReason, + _has_untyped_flag_records, + _UNTYPED_FLAGS_RESPONSE_MESSAGE, normalize_flags_response, to_flags_and_payloads, to_payloads, @@ -1003,6 +1005,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 @@ -1662,6 +1666,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 @@ -4987,6 +4996,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 13d8c7df9..ef4e401d9 100644 --- a/posthog/feature_flag_evaluations.py +++ b/posthog/feature_flag_evaluations.py @@ -9,7 +9,7 @@ from types import MappingProxyType from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Union -from posthog.types import FlagValue, UnresolvedFlagReason +from posthog.types import FeatureFlag, FlagValue, UnresolvedFlagReason @dataclass(frozen=True) @@ -27,6 +27,9 @@ class _EvaluatedFlagRecord: # Server-reported signal for whether the flag is linked to an experiment. # ``None`` when the server did not report it (older deployments). has_experiment: Optional[bool] = None + # The parsed /flags record, with its typed value, reason and metadata. + # ``None`` for a locally evaluated flag. + details: Optional[FeatureFlag] = None @dataclass 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..29db7e61b 100644 --- a/posthog/test/test_feature_flag.py +++ b/posthog/test/test_feature_flag.py @@ -1,6 +1,12 @@ import unittest -from posthog.types import FeatureFlag, FlagMetadata, FlagReason, LegacyFlagMetadata +from posthog.types import ( + FeatureFlag, + FlagMetadata, + FlagReason, + LegacyFlagMetadata, + _set_private_fields, +) class TestFeatureFlag(unittest.TestCase): @@ -38,11 +44,14 @@ def test_feature_flag_from_json(self): ) self.assertEqual( flag.metadata, - FlagMetadata( - id=1, - payload='{"some": "json"}', - version=2, - description="test-description", + _set_private_fields( + FlagMetadata( + id=1, + payload='{"some": "json"}', + version=2, + description="test-description", + ), + _config_version=1, ), ) @@ -127,7 +136,7 @@ def test_flag_metadata_from_json(self): metadata = FlagMetadata.from_json(resp) self.assertEqual(metadata.id, 123) self.assertIsNone(metadata.payload) - self.assertEqual(metadata.version, 0) # default value + self.assertIsNone(metadata.version) # absent, not invented self.assertEqual(metadata.description, "") # default value # Test with None diff --git a/posthog/test/test_flags_v4_response.py b/posthog/test/test_flags_v4_response.py new file mode 100644 index 000000000..a0b3011ab --- /dev/null +++ b/posthog/test/test_flags_v4_response.py @@ -0,0 +1,1052 @@ +"""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, 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, + FlagMetadata, + FlagReason, + _set_private_fields, + 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 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 + + +# The /flags?v=4 metadata fields that stay private until the typed accessors ship. +_PRIVATE_METADATA_FIELDS = { + "config_version", + "rule_type", + "rule_id", + "variant_key", + "forced_variant", +} + +VALID_RESPONSES = [case for case in RESPONSES["cases"] if case["expected"] == "valid"] + + +@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._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"): + if field in _PRIVATE_METADATA_FIELDS: + field = f"_{field}" + 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, + } + + +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, + } + + +@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_flag("flag") is True + logged = [r for r in caplog.records if "no typed flag values" in r.getMessage()] + assert len(logged) == 1 + + +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_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._failed is True + assert malformed._value is None + assert (malformed.enabled, malformed.variant, malformed.get_value()) == ( + False, + None, + False, + ) + # The record keeps that it could not be read, apart from a server failure. + assert malformed._malformed_field == path + sibling = flags["sibling"] + assert sibling._failed is False + assert sibling._value == "compact" + assert sibling._malformed_field is None + + +def test_a_record_that_is_not_an_object_fails_only_that_record(): + response = {"flags": {"broken": ["compact"], "sibling": _record(key="sibling")}} + + flags = normalize_flags_response(response)["flags"] + + assert flags["broken"]._failed is True + assert flags["broken"].get_value() is False + 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._failed is False + 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._failed is False + 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_is_kept_as_failed(): + 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._failed is True + assert flag._value is False + assert flag.reason is not None and flag.reason.description == "Timed out" + + +def _older_record(**overrides: Any) -> Dict[str, Any]: + record: Dict[str, Any] = { + "key": "garden-layout", + "enabled": True, + "variant": "compact", + "failed": False, + "reason": { + "code": "condition_match", + "condition_index": 0, + "description": "Matched condition set 1", + }, + "metadata": { + "id": 202, + "version": 9, + "description": "Garden layout", + "payload": None, + "has_experiment": False, + }, + } + for path, value in overrides.items(): + *parents, last = path.split(".") + target = record + for token in parents: + target = target[token] + target[last] = value + return record + + +def test_one_unreadable_older_server_record_keeps_its_siblings(): + response = { + "flags": { + "bad": { + "enabled": True, + "variant": None, + "reason": "oops", + "metadata": {"id": 1, "version": 1, "payload": None}, + }, + "good": { + "enabled": True, + "variant": None, + "reason": None, + "metadata": {"id": 2, "version": 1, "payload": None}, + }, + }, + "requestId": "x", + } + + flags = normalize_flags_response(response)["flags"] + + assert flags["bad"]._failed is True + assert flags["bad"]._malformed_field == "reason" + assert flags["good"]._failed is False + assert flags["good"].get_value() is True + + +def test_a_value_too_deeply_nested_to_encode_fails_only_that_record(): + deep: Dict[str, Any] = {} + inner = deep + for _ in range(200_000): + inner["a"] = {} + inner = inner["a"] + response = { + "flags": { + "deep": _record(key="deep", value=deep), + "sibling": _record(key="sibling"), + } + } + + flags = normalize_flags_response(response)["flags"] + + assert flags["deep"]._failed is True + assert flags["deep"]._malformed_field == "value" + assert flags["sibling"].get_value() == "compact" + + +@pytest.mark.parametrize( + "path, value", + [ + ("key", 123), + ("enabled", "true"), + ("variant", 3), + ("failed", "yes"), + ("reason", "condition_match"), + ("reason", ""), + ("reason.code", 3), + ("reason.condition_index", "0"), + ("reason.condition_index", 0.5), + ("reason.description", ["Matched"]), + ("metadata", []), + ("metadata.id", "202"), + ("metadata.version", True), + ("metadata.description", 1), + ("metadata.has_experiment", "false"), + ], +) +def test_a_known_field_with_the_wrong_type_fails_only_that_older_server_record( + path, value +): + response = { + "flags": { + "garden-layout": _older_record(**{path: value}), + "sibling": _older_record(key="sibling"), + } + } + + flags = normalize_flags_response(response)["flags"] + + malformed = flags["garden-layout"] + assert malformed.key == "garden-layout" + assert malformed._failed is True + assert malformed._malformed_field == path + assert malformed._value is None + assert (malformed.enabled, malformed.variant, malformed.get_value()) == ( + False, + None, + False, + ) + sibling = flags["sibling"] + assert ( + sibling + == normalize_flags_response( + {"flags": {"sibling": _older_record(key="sibling")}} + )["flags"]["sibling"] + ) + assert (sibling._failed, sibling._value, sibling._malformed_field) == ( + False, + "compact", + None, + ) + + +def test_a_valid_older_server_record_renders_as_before(): + flag = normalize_flags_response({"flags": {"garden-layout": _older_record()}})[ + "flags" + ]["garden-layout"] + + assert flag == FeatureFlag( + key="garden-layout", + enabled=True, + variant="compact", + reason=FlagReason( + code="condition_match", + condition_index=0, + description="Matched condition set 1", + ), + metadata=_set_private_fields( + FlagMetadata( + id=202, + payload=None, + version=9, + description="Garden layout", + has_experiment=False, + ), + _config_version=1, + ), + ) + + +@pytest.mark.parametrize("make_record", [_record, _older_record]) +def test_a_record_key_of_the_wrong_type_fails_only_that_record(make_record): + flags = normalize_flags_response( + { + "flags": { + "garden-layout": make_record(key=123), + "sibling": make_record(key="sibling"), + } + } + )["flags"] + + assert flags["garden-layout"].key == "garden-layout" + assert flags["garden-layout"]._failed is True + assert flags["garden-layout"]._malformed_field == "key" + assert flags["sibling"]._failed is False + + +@pytest.mark.parametrize("make_record", [_record, _older_record]) +@pytest.mark.parametrize("key", ["other-flag", None]) +def test_a_record_is_read_under_its_map_key(make_record, key): + # Reader rules fail a record only for a field of the wrong JSON type. A string + # key that differs from the map key is a producer error, so the map key wins. + record = make_record(key=key) + + flag = normalize_flags_response({"flags": {"garden-layout": record}})["flags"][ + "garden-layout" + ] + + assert flag.key == "garden-layout" + assert flag._failed is False + assert flag._value == "compact" + + +def test_a_server_failed_record_is_not_marked_unreadable(): + flags = normalize_flags_response( + { + "flags": { + "v4": _build(RESPONSES, {"template": "error"})["flags"][ + "garden-layout" + ], + "older": _older_record(failed=True, enabled=False, variant=None), + } + } + )["flags"] + + for flag in flags.values(): + assert flag._failed is True + assert flag._malformed_field is None + + +@pytest.mark.parametrize("make_record", [_record, _older_record]) +def test_absent_flag_id_and_version_stay_absent(make_record, client): + record = make_record() + record["key"] = "garden-layout" + del record["metadata"]["id"] + del record["metadata"]["version"] + response = { + "flags": {"garden-layout": record}, + "errorsWhileComputingFlags": False, + "requestId": "60000000-0000-4000-8000-000000000000", + } + + flag = normalize_flags_response(copy.deepcopy(response))["flags"]["garden-layout"] + assert isinstance(flag.metadata, FlagMetadata) + assert (flag.metadata.id, flag.metadata.version) == (None, None) + + with ( + mock.patch("posthog.client.flags", return_value=copy.deepcopy(response)), + mock.patch.object(client, "capture") as capture, + ): + client.get_feature_flag_result("garden-layout", DISTINCT_ID) + properties = capture.call_args.kwargs["properties"] + assert "$feature_flag_id" not in properties + assert "$feature_flag_version" not in properties + + # A new client, so the event is not deduplicated against the one above. + snapshot_client = Client(FAKE_TEST_API_KEY, send=False) + try: + with mock.patch.object(snapshot_client, "capture") as capture: + _evaluate(snapshot_client, response).get_flag("garden-layout") + finally: + snapshot_client.shutdown() + properties = capture.call_args.kwargs["properties"] + assert "$feature_flag_id" not in properties + assert "$feature_flag_version" not in properties + + +@pytest.mark.parametrize( + "value, encoded", [(25, "25"), ({"columns": 2}, '{"columns":2}')] +) +def test_a_number_or_object_value_is_its_own_payload(value, encoded, client): + # A config version 2 number or object value is its payload; a payload the + # record also sends is ignored. + response = _typed_response({"upload-limit": value}) + response["flags"]["upload-limit"]["metadata"]["payload"] = "999" + + flag = normalize_flags_response(copy.deepcopy(response))["flags"]["upload-limit"] + assert flag.metadata.payload == encoded + + snapshot = _evaluate(client, response) + assert snapshot.get_flag_payload("upload-limit") == value + + +def test_a_config_version_1_payload_is_kept(): + response = _typed_response({"bool-flag": True, "string-flag": "compact"}) + for record in response["flags"].values(): + record["metadata"].update(config_version=1, payload='{"columns": 2}') + + flags = normalize_flags_response(response)["flags"] + + assert flags["bool-flag"].metadata.payload == '{"columns": 2}' + assert flags["string-flag"].metadata.payload == '{"columns": 2}' + + +def test_feature_flag_value_defaults_to_the_legacy_value(): + assert FeatureFlag.from_value_and_payload("a", "control", None)._value == "control" + assert FeatureFlag.from_value_and_payload("a", True, None)._value is True + 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, + ) + + +async def test_async_client_keeps_healthy_flags_beside_unreadable_and_sparse_ones(): + sparse = _record(key="sparse") + del sparse["metadata"]["id"], sparse["metadata"]["version"] + response = { + "flags": { + "bad-older": _older_record(key="bad-older", reason="oops"), + "bad-v4": _record(key="bad-v4", **{"metadata.id": "202"}), + "sparse": sparse, + "healthy": _older_record(key="healthy"), + }, + "requestId": "x", + } + with mock.patch( + "posthog.async_client._async_flags", new=mock.AsyncMock(return_value=response) + ): + client = AsyncPosthog("project-key", send=False) + with mock.patch.object(client, "capture") as capture: + snapshot = await client.evaluate_flags(DISTINCT_ID) + assert snapshot.get_flag("bad-older") is False + assert snapshot.get_flag("bad-v4") is False + assert snapshot.get_flag("sparse") == "compact" + assert snapshot.get_flag("healthy") == "compact" + await client.shutdown() + + sparse_event = next( + call.kwargs["properties"] + for call in capture.call_args_list + if call.kwargs["properties"]["$feature_flag"] == "sparse" + ) + assert "$feature_flag_id" not in sparse_event + assert "$feature_flag_version" not in sparse_event + + +async def test_async_client_reads_the_v4_response(): + with mock.patch( + "posthog.async_client._async_flags", + new=mock.AsyncMock( + return_value=_typed_response( + {"float-flag": 12.5, "object-flag": {"layout": "compact"}} + ) + ), + ): + client = AsyncPosthog("project-key", send=False) + snapshot = await client.evaluate_flags(DISTINCT_ID) + await client.shutdown() + + assert snapshot.get_flag("object-flag") is True + assert snapshot.get_flag_payload("object-flag") == {"layout": "compact"} + assert snapshot.get_flag("float-flag") is True + assert snapshot.get_flag_payload("float-flag") == 12.5 diff --git a/posthog/types.py b/posthog/types.py index 994b0786c..bc1dee736 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -1,8 +1,19 @@ 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, + List, + Optional, + TypedDict, + TypeVar, + Union, + cast, +) FlagValue = Union[bool, str] @@ -162,40 +173,81 @@ class FlagMetadata: """Feature flag metadata returned by the feature flag API. Attributes: - id: Numeric feature flag ID. - payload: Payload configured for the matched flag value, if any. - version: Feature flag version. + id: Numeric feature flag ID, or ``None`` when the record has none. + payload: Payload configured for the matched flag value, if any. For a + number or object value it is the value encoded as JSON, the way + servers before ``/flags?v=4`` sent it. + version: Feature flag version, or ``None`` when the record has none. description: Feature flag description. has_experiment: Whether the flag has a linked experiment. ``None`` when the server does not report the field (older deployments). """ - id: int + id: Optional[int] payload: Optional[str] - version: int + version: Optional[int] description: str has_experiment: Optional[bool] = None + # Work in progress: the ``/flags?v=4`` metadata fields are private until the + # typed flag value accessors ship, which make them public. ``_config_version`` + # is the configuration format of the flag (``1`` for a record without a typed + # ``value``); the rule fields name the rule that produced the result, and the + # variant fields the assigned variant. + _config_version: Optional[int] = field(default=None, init=False) + _rule_type: Optional[str] = field(default=None, init=False) + _rule_id: Optional[str] = field(default=None, init=False) + _variant_key: Optional[str] = field(default=None, init=False) + _forced_variant: Optional[bool] = field(default=None, init=False) @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") - return cls( - id=resp.get("id", 0), + metadata = cls( + id=resp.get("id"), payload=resp.get("payload"), - version=resp.get("version", 0), + version=resp.get("version"), description=resp.get("description", ""), has_experiment=raw_has_experiment if isinstance(raw_has_experiment, bool) else None, ) + return _set_private_fields(metadata, _config_version=1) + + +_T = TypeVar("_T") + + +def _set_private_fields(obj: _T, **fields: Any) -> _T: + """Set the private, work-in-progress fields of a frozen dataclass.""" + for name, value in fields.items(): + object.__setattr__(obj, name, value) + return obj + + +class _DerivedValue: + """Default of ``FeatureFlag._value``: derive the value from ``variant`` and ``enabled``.""" + + def __repr__(self) -> str: + return "" + + +_DERIVED_VALUE: Any = _DerivedValue() @dataclass(frozen=True) class FeatureFlag: """Detailed feature flag evaluation returned by the flags API. + ``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. @@ -209,29 +261,40 @@ class FeatureFlag: variant: Optional[str] reason: Optional[FlagReason] metadata: Union[FlagMetadata, LegacyFlagMetadata] + # Work in progress: private until the typed flag value accessors ship, which + # make them public. ``_value`` is the typed flag value: a boolean, string, + # number, object, or ``None`` when the flag has no value; it defaults to + # ``variant``, or ``enabled`` when there is no variant. ``_failed`` says whether + # the flag failed to evaluate, or its record could not be read. + _value: Any = field(default=_DERIVED_VALUE, init=False) + _failed: bool = field(default=False, init=False) + # The wire path of the field that made the record unreadable, such as + # ``"metadata.id"``. ``None`` unless the record could not be read; a record the + # server marks failed has ``_failed`` set and this ``None``. + _malformed_field: Optional[str] = field( + default=None, init=False, repr=False, compare=False + ) + + def __post_init__(self) -> None: + if self._value is _DERIVED_VALUE: + 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": - reason = None - if resp.get("reason"): - reason = FlagReason.from_json(resp.get("reason")) - - metadata = None - if resp.get("metadata"): - metadata = FlagMetadata.from_json(resp.get("metadata")) - else: - metadata = LegacyFlagMetadata(payload=None) + """Parse one flag record of a ``/flags`` response. - return cls( - key=resp.get("key"), - enabled=resp.get("enabled"), - variant=resp.get("variant"), - reason=reason, - metadata=metadata, - ) + 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``. + """ + return _parse_record(resp.get("key"), resp) @classmethod def from_value_and_payload( @@ -249,6 +312,227 @@ 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 _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, which then reads as disabled. 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") + _read_record_key(resp) + 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: + metadata = FlagMetadata( + id=_read_int(raw_metadata.get("id"), "metadata.id"), + # A number or object value is its own payload; a payload the record + # also carries is ignored. + payload=encoded_payload + if encoded_payload is not None + else raw_metadata.get("payload"), + version=_read_int(raw_metadata.get("version"), "metadata.version"), + description=_read_typed( + raw_metadata.get("description", ""), "metadata.description", str + ), + has_experiment=_read_typed( + raw_metadata.get("has_experiment"), "metadata.has_experiment", bool + ), + ) + _set_private_fields( + metadata, + _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 _malformed_record(key, str(error)) + except RecursionError: + # A value nested too deeply to encode as a payload fails only this record. + return _malformed_record(key, "value") + + flag = FeatureFlag( + key=key, + enabled=enabled, + variant=variant, + reason=reason, + metadata=metadata, + ) + return _set_private_fields(flag, _value=value, _failed=failed) + + +def _parse_older_record(key: str, resp: Dict[str, Any]) -> FeatureFlag: + """Parse a record without a typed ``value``, from a server before ``/flags?v=4``. + + Its value is ``variant``, or ``enabled`` when there is no variant, and its config + version is ``1``. As for a ``/flags?v=4`` record, a known field with the wrong + JSON type fails only this record. + """ + try: + _read_record_key(resp) + _read_typed(resp.get("enabled"), "enabled", bool) + _read_typed(resp.get("variant"), "variant", str) + _read_typed(resp.get("failed"), "failed", bool) + raw_reason = _read_typed(resp.get("reason"), "reason", dict) + if raw_reason: + _read_typed(raw_reason.get("code"), "reason.code", str) + _read_int(raw_reason.get("condition_index"), "reason.condition_index") + _read_typed(raw_reason.get("description"), "reason.description", str) + raw_metadata = _read_typed(resp.get("metadata"), "metadata", dict) + if raw_metadata: + _read_int(raw_metadata.get("id"), "metadata.id") + _read_int(raw_metadata.get("version"), "metadata.version") + _read_typed(raw_metadata.get("description"), "metadata.description", str) + _read_typed( + raw_metadata.get("has_experiment"), "metadata.has_experiment", bool + ) + except _MalformedRecord as error: + return _malformed_record(key, str(error)) + + flag = FeatureFlag( + key=key, + # An older server always sends ``enabled``; a record without it keeps + # ``None``, as before. + enabled=cast(bool, resp.get("enabled")), + variant=resp.get("variant"), + reason=FlagReason.from_json(raw_reason), + metadata=FlagMetadata.from_json(raw_metadata), + ) + return _set_private_fields(flag, _failed=resp.get("failed") is True) + + +def _read_record_key(resp: Dict[str, Any]) -> None: + # The flag key is the record's map key. A ``key`` member of the wrong type fails + # the record, like any known field; a string that differs is ignored. + _read_typed(resp.get("key"), "key", str) + + +def _malformed_record(key: str, malformed_field: str) -> FeatureFlag: + """A record with a known field of the wrong JSON type: failed, and disabled.""" + flag = FeatureFlag( + key=key, + enabled=False, + variant=None, + reason=None, + metadata=LegacyFlagMetadata(payload=None), + ) + return _set_private_fields( + flag, _value=None, _failed=True, _malformed_field=malformed_field + ) + + +def _parse_record(key: str, resp: Any) -> FeatureFlag: + """Parse one record of a ``/flags`` response, stored under ``key``.""" + if isinstance(resp, dict) and "value" not in resp: + return _parse_older_record(key, resp) + return _parse_typed_record(key, resp) + + +def _has_untyped_flag_records(resp: Any) -> bool: + """Whether a raw ``/flags`` response has a record without a typed ``value``. + + 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. Every flag is read as config version 1." +) + + class FlagsResponse(TypedDict, total=False): """Normalized response from the PostHog feature flags API.""" @@ -366,7 +650,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. @@ -379,8 +663,7 @@ def normalize_flags_response(resp: Any) -> FlagsResponse: for key, value in flags.items(): if isinstance(value, FeatureFlag): continue - value["key"] = key - flags[key] = FeatureFlag.from_json(value) + flags[key] = _parse_record(key, value) else: # Handle legacy format featureFlags = resp.get("featureFlags", {}) diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 63ce6a504..841ebabbc 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -321,6 +321,7 @@ alias posthog.exception_capture.BucketedRateLimiter -> posthog.bucketed_rate_lim alias posthog.exception_capture.Client -> posthog.client.Client alias posthog.exception_utils.ExcInfo -> posthog.args.ExcInfo alias posthog.exception_utils.ExceptionArg -> posthog.args.ExceptionArg +alias posthog.feature_flag_evaluations.FeatureFlag -> posthog.types.FeatureFlag alias posthog.feature_flag_evaluations.FlagValue -> posthog.types.FlagValue alias posthog.feature_flag_evaluations.UnresolvedFlagReason -> posthog.types.UnresolvedFlagReason alias posthog.feature_flags.FlagValue -> posthog.types.FlagValue @@ -1149,9 +1150,9 @@ attribute posthog.types.FeatureFlagResult.reason: Optional[str] attribute posthog.types.FeatureFlagResult.variant: Optional[str] attribute posthog.types.FlagMetadata.description: str attribute posthog.types.FlagMetadata.has_experiment: Optional[bool] = None -attribute posthog.types.FlagMetadata.id: int +attribute posthog.types.FlagMetadata.id: Optional[int] attribute posthog.types.FlagMetadata.payload: Optional[str] -attribute posthog.types.FlagMetadata.version: int +attribute posthog.types.FlagMetadata.version: Optional[int] attribute posthog.types.FlagReason.code: str attribute posthog.types.FlagReason.condition_index: Optional[int] attribute posthog.types.FlagReason.description: str @@ -1339,7 +1340,7 @@ class posthog.types.FeatureFlag(key: str, enabled: bool, variant: Optional[str], class posthog.types.FeatureFlagError class posthog.types.FeatureFlagEvaluationRuntime class posthog.types.FeatureFlagResult(key: str, enabled: bool, variant: Optional[str], payload: Optional[Any], reason: Optional[str]) -class posthog.types.FlagMetadata(id: int, payload: Optional[str], version: int, description: str, has_experiment: Optional[bool] = None) +class posthog.types.FlagMetadata(id: Optional[int], payload: Optional[str], version: Optional[int], description: str, has_experiment: Optional[bool] = None) class posthog.types.FlagReason(code: str, condition_index: Optional[int], description: str) class posthog.types.FlagsAndPayloads class posthog.types.FlagsResponse 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