diff --git a/.sampo/changesets/unresolved-local-flags.md b/.sampo/changesets/unresolved-local-flags.md new file mode 100644 index 000000000..135465dc9 --- /dev/null +++ b/.sampo/changesets/unresolved-local-flags.md @@ -0,0 +1,13 @@ +--- +pypi/posthog: minor +--- + +Report flags that local evaluation could not resolve on `evaluate_flags()` snapshots. `FeatureFlagEvaluations.unresolved_flags` maps each such flag to an `UnresolvedFlagReason` (`experience_continuity`, `unsupported_definition`, `unresolved_dependency` or `missing_context`), and reading one reports the `local_evaluation_inconclusive` error instead of `flag_missing`. When definitions load, log one warning per flag that local evaluation can never resolve (experience continuity, a static cohort, an unknown operator, a malformed value, or a missing or circular flag dependency), and again only when its definition changes. `AsyncClient` evaluates only remotely, so its snapshots have no unresolved flags. + +Behavior changes: + +- A condition with a filter that local evaluation can't check and a filter that does not match now resolves locally as no match. Before, such a flag fell back to a remote request, or stayed unresolved with `only_evaluate_locally=True`. +- An inactive flag with experience continuity enabled now resolves to `false` locally instead of falling back to a remote request. +- A flag that depends on a flag with experience continuity enabled is no longer evaluated locally. It falls back to a remote request, or stays unresolved with `only_evaluate_locally=True`. +- On `$feature_flag_called` events from an `evaluate_flags()` snapshot, `$feature_flag_error` is now `local_evaluation_inconclusive` instead of `flag_missing` for a flag that has a loaded definition but that local evaluation could not resolve. +- `get_all_flags()`, `get_all_flags_and_payloads()` and `evaluate_flags()` no longer log an error with a traceback when a flag needs the server (for example, a static cohort). They fall back to a remote request without logging, as `get_feature_flag()` already did. diff --git a/posthog/__init__.py b/posthog/__init__.py index 2e870403c..a4c3f3678 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -83,6 +83,7 @@ FeatureFlagEvaluationRuntime as FeatureFlagEvaluationRuntime, FlagValue as FlagValue, FlagsAndPayloads as FlagsAndPayloads, + UnresolvedFlagReason as UnresolvedFlagReason, ) from posthog.types import ( FeatureFlagResult as FeatureFlagResult, @@ -1104,7 +1105,8 @@ def evaluate_flags( person_properties: Person properties to use for evaluation. group_properties: Group properties keyed by group type. only_evaluate_locally: If ``True``, never fall back to remote evaluation and - omit flags that cannot be evaluated locally. + omit flags that cannot be evaluated locally. The snapshot's + ``unresolved_flags`` lists them with a reason. disable_geoip: Whether to disable GeoIP lookup. flag_keys: Optional list that scopes local evaluation, the underlying ``/flags`` request, and the returned snapshot. When omitted or ``None``, all flags are evaluated. diff --git a/posthog/async_client.py b/posthog/async_client.py index 186bff727..d1015ebba 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -893,6 +893,8 @@ async def evaluate_flags( The returned snapshot has synchronous in-memory accessors. Pass it to ``capture(flags=...)`` to attach the exact values used for branching. + This client does not evaluate flags locally, so the snapshot's + ``unresolved_flags`` is always empty. """ host = self._get_feature_flag_evaluations_host() if distinct_id is None: diff --git a/posthog/client.py b/posthog/client.py index bff6def5d..5d0a92ca4 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -11,7 +11,7 @@ import weakref from contextvars import ContextVar from datetime import datetime, timedelta, timezone -from typing import Any, Callable, Dict, List, Mapping, Optional, Union, cast +from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, Union, cast from uuid import UUID, uuid4 from typing_extensions import Unpack @@ -77,6 +77,7 @@ from posthog.feature_flags import ( InconclusiveMatchError, RequiresServerEvaluation, + _get_definition_unresolved_reason, match_feature_flag_properties, resolve_bucketing_value, ) @@ -115,6 +116,7 @@ FlagsResponse, FlagValue, SendFeatureFlagsOptions, + UnresolvedFlagReason, normalize_flags_response, to_flags_and_payloads, to_payloads, @@ -926,6 +928,11 @@ def __init__( self._flag_definition_cache_generation = 0 self._flag_definition_publication_lock = threading.RLock() self._flag_definition_cache_write_lock = threading.RLock() + # Flag key -> definition fingerprint of flags already warned about as never + # resolvable locally, so each warning fires once until the definition changes. + self._warned_unresolvable_flags: Dict[str, str] = {} + # Definitions fingerprint of the last scan for never-resolvable flags. + self._unresolvable_flags_fingerprint: Optional[str] = None self._flag_definition_cache_provider = flag_definition_cache_provider self._flag_definition_cache_provider_async_runner: Optional[ _BackgroundEventLoopRunner @@ -3262,6 +3269,101 @@ def _update_flag_state( return old_fingerprint return None + def _warn_unresolvable_flags(self) -> None: + """Log flags the published definitions can never resolve locally. + + Call it after publication, outside the publication lock: classifying every + flag walks its cohorts, and log handlers can be slow.""" + with self._flag_definition_publication_lock: + definitions_fingerprint = self._flag_definition_fingerprint + if definitions_fingerprint == self._unresolvable_flags_fingerprint: + return + snapshot = self._local_evaluation_snapshot() + + unresolvable = self._classify_unresolvable_flags(snapshot) + + with self._flag_definition_publication_lock: + # Newer definitions or another caller own the warning for this state. + if ( + definitions_fingerprint != self._flag_definition_fingerprint + or definitions_fingerprint == self._unresolvable_flags_fingerprint + ): + return + self._unresolvable_flags_fingerprint = definitions_fingerprint + new_keys_by_reason: Dict[UnresolvedFlagReason, List[str]] = {} + for key, (reason, fingerprint) in unresolvable.items(): + if self._warned_unresolvable_flags.get(key) != fingerprint: + new_keys_by_reason.setdefault(reason, []).append(key) + # Rebuilt from the current definitions, so a flag that is deleted or + # becomes resolvable is forgotten and warns again if it later becomes + # unresolvable. + self._warned_unresolvable_flags = { + key: fingerprint for key, (_, fingerprint) in unresolvable.items() + } + + self._log_unresolvable_flags(new_keys_by_reason) + + @staticmethod + def _classify_unresolvable_flags( + snapshot: _LocalEvaluationSnapshot, + ) -> Dict[str, Tuple[UnresolvedFlagReason, str]]: + """Map each never-resolvable flag key to its reason and a fingerprint of its + definition.""" + unresolvable: Dict[str, Tuple[UnresolvedFlagReason, str]] = {} + for flag in snapshot["flags"]: + try: + key = flag["key"] + reason = _get_definition_unresolved_reason( + flag, + snapshot["flags_by_key"], + snapshot["cohorts"], + snapshot["group_type_mapping"], + ) + except Exception: + # Evaluation reports a definition this malformed when the flag is used. + continue + if reason is None: + continue + fingerprint = _hashlib.sha256( + json.dumps([reason.value, flag], sort_keys=True, default=str).encode( + "utf-8" + ) + ).hexdigest() + unresolvable[str(key)] = (reason, fingerprint) + return unresolvable + + def _log_unresolvable_flags( + self, new_keys_by_reason: Dict[UnresolvedFlagReason, List[str]] + ) -> None: + experience_continuity_keys = new_keys_by_reason.get( + UnresolvedFlagReason.EXPERIENCE_CONTINUITY + ) + if experience_continuity_keys: + self.log.warning( + f"[FEATURE FLAGS] {len(experience_continuity_keys)} flag(s) have " + "experience continuity enabled and can't be evaluated locally " + f"(reason: {UnresolvedFlagReason.EXPERIENCE_CONTINUITY.value}): " + f"{', '.join(experience_continuity_keys)}. Evaluating them makes a " + "remote request, or leaves them unresolved with " + "only_evaluate_locally=True. To evaluate them locally, disable " + "experience continuity on these flags in PostHog." + ) + unsupported_definition_keys = new_keys_by_reason.get( + UnresolvedFlagReason.UNSUPPORTED_DEFINITION + ) + if unsupported_definition_keys: + self.log.warning( + f"[FEATURE FLAGS] {len(unsupported_definition_keys)} flag(s) use " + "something local evaluation doesn't support, such as a static " + "cohort, an unknown operator, a malformed value, or a missing or " + "circular flag dependency " + f"(reason: {UnresolvedFlagReason.UNSUPPORTED_DEFINITION.value}): " + f"{', '.join(unsupported_definition_keys)}. Evaluating them makes a " + "remote request when evaluation reaches that part, or leaves them " + "unresolved with only_evaluate_locally=True. To evaluate them " + "locally, change these flags in PostHog or upgrade posthog." + ) + def _local_evaluation_snapshot(self) -> _LocalEvaluationSnapshot: # Capture references together. Publication replaces these collections, so # recursive and multi-flag evaluations can finish on their original rules. @@ -3301,6 +3403,7 @@ def _load_feature_flags(self): "[FEATURE FLAGS] Using cached flag definitions from external cache" ) old_fingerprint = self._update_flag_state(cached_data) + self._warn_unresolvable_flags() if isinstance(self.flag_cache, RedisFlagCache): self.flag_cache._invalidate_snapshot(old_fingerprint) self._last_feature_flag_poll = datetime.now(tz=timezone.utc) @@ -3406,6 +3509,8 @@ def _fetch_feature_flags_from_api(self): self._flag_definition_published_generation = fetch_generation self._flag_definition_cache_generation = fetch_generation + self._warn_unresolvable_flags() + if cache_data_to_store and self._flag_definition_cache_provider: # Keep provider I/O out of the publication lock. The separate lock # preserves cache write order without delaying newer API fetches or @@ -3567,12 +3672,15 @@ def _compute_flag_locally( # Create evaluation cache for flag dependencies evaluation_cache: dict[str, Optional[FlagValue]] = {} - if feature_flag.get("ensure_experience_continuity", False): - raise InconclusiveMatchError("Flag has experience continuity enabled") - if not feature_flag.get("active"): return False + if feature_flag.get("ensure_experience_continuity", False): + raise InconclusiveMatchError( + "Flag has experience continuity enabled", + reason=UnresolvedFlagReason.EXPERIENCE_CONTINUITY, + ) + flag_filters = feature_flag.get("filters") or {} aggregation_group_type_index = flag_filters.get("aggregation_group_type_index") group_type_mapping = snapshot["group_type_mapping"] @@ -3585,7 +3693,10 @@ def _compute_flag_locally( f"[FEATURE FLAGS] Unknown group type index {aggregation_group_type_index} for feature flag {feature_flag['key']}" ) # failover to `/flags` - raise InconclusiveMatchError("Flag has unknown group type index") + raise InconclusiveMatchError( + "Flag has unknown group type index", + reason=UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) if group_name not in groups: # Group flags are never enabled in `groups` aren't passed in @@ -3602,7 +3713,8 @@ def _compute_flag_locally( if group_name not in group_properties: raise InconclusiveMatchError( - f"Flag has no group properties for group '{group_name}'" + f"Flag has no group properties for group '{group_name}'", + reason=UnresolvedFlagReason.MISSING_CONTEXT, ) focused_group_properties = group_properties[group_name] group_key = groups[group_name] @@ -3621,9 +3733,23 @@ def _compute_flag_locally( group_properties=group_properties, ) else: - bucketing_value = resolve_bucketing_value( - feature_flag, distinct_id, device_id - ) + try: + bucketing_value = resolve_bucketing_value( + feature_flag, distinct_id, device_id + ) + except InconclusiveMatchError as e: + # No condition is checked without a bucketing value, so a cause in + # the definition also applies and outranks missing_context. + e.reason = ( + _get_definition_unresolved_reason( + feature_flag, + flags_by_key, + snapshot["cohorts"], + group_type_mapping, + ) + or e.reason + ) + raise return match_feature_flag_properties( feature_flag, distinct_id, @@ -4539,7 +4665,8 @@ def evaluate_flags( person_properties: Person properties to use for evaluation. group_properties: Group properties keyed by group type. only_evaluate_locally: If True, never fall back to remote evaluation — - flags that can't be evaluated locally are simply omitted from the snapshot. + flags that can't be evaluated locally are omitted from the snapshot and + listed with a reason in its ``unresolved_flags``. disable_geoip: Whether to disable GeoIP lookup. flag_keys: Optional list that scopes local evaluation, the underlying ``/flags`` request, and the returned snapshot. When omitted or ``None``, all @@ -4610,6 +4737,7 @@ def evaluate_flags( errors_while_computing = False quota_limited = False locally_evaluated_keys: set[str] = set() + unresolved: Dict[str, UnresolvedFlagReason] = {} # Try local evaluation first when the poller has loaded definitions. local_person_properties = self._person_properties_for_local_evaluation( distinct_id, person_properties @@ -4622,6 +4750,7 @@ def evaluate_flags( group_properties=group_properties, flag_keys_to_evaluate=flag_keys, device_id=device_id, + unresolved=unresolved, ) ) @@ -4732,6 +4861,9 @@ def evaluate_flags( errors_while_computing=errors_while_computing, quota_limited=quota_limited, minimal_flag_called_events=minimal_flag_called_events, + unresolved_flags={ + key: reason for key, reason in unresolved.items() if key not in records + }, ) _feature_flag_evaluations_host_cache: Optional[_FeatureFlagEvaluationsHost] = None @@ -4754,6 +4886,7 @@ def _get_all_flags_and_payloads_locally( warn_on_unknown_groups=False, flag_keys_to_evaluate: Optional[list[str]] = None, device_id: Optional[str] = None, + unresolved: Optional[Dict[str, UnresolvedFlagReason]] = None, ) -> tuple[FlagsAndPayloads, bool, _LocalEvaluationSnapshot]: person_properties = person_properties or {} group_properties = group_properties or {} @@ -4794,9 +4927,11 @@ def _get_all_flags_and_payloads_locally( ) if matched_payload is not None: payloads[flag["key"]] = matched_payload - except InconclusiveMatchError: + except (InconclusiveMatchError, RequiresServerEvaluation) as e: # No need to log this, since it's just telling us to fall back to `/flags` fallback_to_flags = True + if unresolved is not None: + unresolved[flag["key"]] = e.reason except Exception as e: self.log.exception( f"[FEATURE FLAGS] Error while computing variant and payload: {e}" diff --git a/posthog/feature_flag_evaluations.py b/posthog/feature_flag_evaluations.py index c260fbde7..13d8c7df9 100644 --- a/posthog/feature_flag_evaluations.py +++ b/posthog/feature_flag_evaluations.py @@ -6,9 +6,10 @@ """ from dataclasses import dataclass +from types import MappingProxyType from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Union -from posthog.types import FlagValue +from posthog.types import FlagValue, UnresolvedFlagReason @dataclass(frozen=True) @@ -73,6 +74,7 @@ def __init__( quota_limited: bool = False, minimal_flag_called_events: bool = False, accessed: Optional[Set[str]] = None, + unresolved_flags: Optional[Mapping[str, UnresolvedFlagReason]] = None, ) -> None: """Internal — instances are created by the SDK via ``Client.evaluate_flags()``.""" self._host = host @@ -89,6 +91,9 @@ def __init__( # server response, not whatever the client-wide gate happens to be at send time. self._minimal_flag_called_events = minimal_flag_called_events self._accessed: Set[str] = set(accessed) if accessed is not None else set() + self._unresolved_flags: Dict[str, UnresolvedFlagReason] = dict( + unresolved_flags or {} + ) def is_enabled(self, key: str, default_value: bool = False) -> bool: """Return whether the flag is enabled. Fires ``$feature_flag_called`` on the @@ -165,6 +170,22 @@ def keys(self) -> List[str]: """Return the flag keys that are part of this evaluation.""" return list(self._flags.keys()) + @property + def unresolved_flags(self) -> Mapping[str, UnresolvedFlagReason]: + """Return a read-only mapping of flag key to the reason local evaluation + left it without a value. + + A flag is listed when it has a loaded local definition, was in the requested + scope, was inconclusive locally, and no remote value filled the gap. Listed + flags are not part of :attr:`keys`, and reading one reports the + ``local_evaluation_inconclusive`` error instead of ``flag_missing``. Reading + this mapping does not count as an access and does not fire any event. + Snapshots returned by :meth:`only` and :meth:`only_accessed`, and by + ``AsyncClient.evaluate_flags()``, which evaluates only remotely, carry no + entries. + """ + return MappingProxyType(self._unresolved_flags) + # --- Internal ------------------------------------------------------------- def _get_event_properties(self) -> Dict[str, Any]: @@ -256,14 +277,19 @@ def _record_access(self, key: str) -> None: # Build the comma-joined `$feature_flag_error` matching the single-flag path's # granularity: response-level errors (errors-while-computing, quota-limited) are - # combined with per-flag errors (flag-missing) so consumers can filter by type. + # combined with per-flag errors (flag-missing or local-evaluation-inconclusive) + # so consumers can filter by type. errors: List[str] = [] if self._errors_while_computing: errors.append("errors_while_computing_flags") if self._quota_limited: errors.append("quota_limited") if flag is None: - errors.append("flag_missing") + errors.append( + "local_evaluation_inconclusive" + if key in self._unresolved_flags + else "flag_missing" + ) if errors: properties["$feature_flag_error"] = ",".join(errors) diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index 66af600c7..e0ffda658 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -12,7 +12,7 @@ from typing import NamedTuple, NoReturn, Optional from posthog import utils as utils -from posthog.types import FlagValue +from posthog.types import FlagValue, UnresolvedFlagReason from posthog.utils import convert_to_datetime_aware from posthog.utils import is_valid_regex as is_valid_regex @@ -96,19 +96,52 @@ class ConditionMatch(Enum): class InconclusiveMatchError(Exception): - pass + """ + Raised when local evaluation can't determine a flag's value. ``reason`` says + why, as reported by ``FeatureFlagEvaluations.unresolved_flags``. + """ + + reason: UnresolvedFlagReason = UnresolvedFlagReason.UNSUPPORTED_DEFINITION + + def __init__(self, *args, reason: Optional[UnresolvedFlagReason] = None): + super().__init__(*args) + if reason is not None: + self.reason = reason class RequiresServerEvaluation(Exception): """ Raised when feature flag evaluation requires server-side data that is not - available locally (e.g., static cohorts, experience continuity). + available locally (e.g., static cohorts). This error should propagate immediately to trigger API fallback, unlike InconclusiveMatchError which allows trying other conditions. """ - pass + reason: UnresolvedFlagReason = UnresolvedFlagReason.UNSUPPORTED_DEFINITION + + def __init__(self, *args, reason: Optional[UnresolvedFlagReason] = None): + super().__init__(*args) + if reason is not None: + self.reason = reason + + +# When several causes apply to one flag, the spec reports the first reason in this +# order, so every SDK picks the same reason whatever order it inspects filters in. +_UNRESOLVED_REASON_PRECEDENCE = ( + UnresolvedFlagReason.EXPERIENCE_CONTINUITY, + UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + UnresolvedFlagReason.UNRESOLVED_DEPENDENCY, + UnresolvedFlagReason.MISSING_CONTEXT, +) + + +def _pick_unresolved_reason( + current: Optional[UnresolvedFlagReason], new: UnresolvedFlagReason +) -> UnresolvedFlagReason: + if current is None: + return new + return min(current, new, key=_UNRESOLVED_REASON_PRECEDENCE.index) # This function takes a bucketing value and a feature flag key and returns a float between 0 and 1. @@ -180,6 +213,20 @@ def variant_lookup_table(feature_flag): return lookup_table +def _flag_dependency_shape_problem(prop) -> Optional[str]: + """Describe why a flag dependency condition is malformed, or return None. + + Shared by local evaluation and the load-time classifier, so the two agree.""" + operator = prop.get("operator", "exact") + if operator != "flag_evaluates_to": + return f"invalid operator '{operator}'" + # Test `is None`, not truthiness: `False` is a valid expected value (the case + # flag dependencies exist for). + if not prop.get("key") or prop.get("value") is None: + return "missing key or value" + return None + + def evaluate_flag_dependency( property, flags_by_key, @@ -222,40 +269,62 @@ def evaluate_flag_dependency( if flags_by_key is None or evaluation_cache is None: # Cannot evaluate flag dependencies without required context raise InconclusiveMatchError( - f"Cannot evaluate flag dependency on '{property.get('key', 'unknown')}' without flags_by_key and evaluation_cache" + f"Cannot evaluate flag dependency on '{property.get('key', 'unknown')}' without flags_by_key and evaluation_cache", + reason=UnresolvedFlagReason.UNSUPPORTED_DEFINITION, ) # Validate the condition shape before walking the chain. A malformed shape # is a definitive no-match (return False), mirroring the server-side Rust # evaluator (match_flag_value_to_flag_filter). Raising InconclusiveMatchError # here would force a billable /flags network fallback on every evaluation of - # the affected flag. Test `is None`, not truthiness: `False` is a valid - # expected value (the case flag dependencies exist for). + # the affected flag. flag_key = property.get("key") expected_value = property.get("value") - operator = property.get("operator", "exact") - if operator != "flag_evaluates_to": - _warn_malformed_flag_dependency_once(flag_key, f"invalid operator '{operator}'") - return False - if not flag_key or expected_value is None: - _warn_malformed_flag_dependency_once(flag_key, "missing key or value") + shape_problem = _flag_dependency_shape_problem(property) + if shape_problem is not None: + _warn_malformed_flag_dependency_once(flag_key, shape_problem) return False # Check if dependency_chain is present - it should always be provided for flag dependencies if "dependency_chain" not in property: # Missing dependency_chain indicates malformed server data raise InconclusiveMatchError( - f"Flag dependency property for '{property.get('key', 'unknown')}' is missing required 'dependency_chain' field" + f"Flag dependency property for '{property.get('key', 'unknown')}' is missing required 'dependency_chain' field", + reason=UnresolvedFlagReason.UNSUPPORTED_DEFINITION, ) dependency_chain = property["dependency_chain"] + if not isinstance(dependency_chain, list): + raise InconclusiveMatchError( + f"Flag dependency property for '{flag_key}' has an invalid 'dependency_chain' field", + reason=UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) + # Handle circular dependency (empty chain means circular) if len(dependency_chain) == 0: log.debug(f"Circular dependency detected for flag: {property.get('key')}") raise InconclusiveMatchError( - f"Circular dependency detected for flag '{property.get('key', 'unknown')}'" + f"Circular dependency detected for flag '{property.get('key', 'unknown')}'", + reason=UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) + + # A dependency with no definition outranks one that is only inconclusive, so + # check the whole chain before evaluating any of it. + missing_dependency = next( + ( + key + for key in dependency_chain + if key not in evaluation_cache and not flags_by_key.get(key) + ), + None, + ) + if missing_dependency is not None: + evaluation_cache[missing_dependency] = None + raise InconclusiveMatchError( + f"Cannot evaluate flag dependency '{missing_dependency}' - flag not found in local flags", + reason=UnresolvedFlagReason.UNSUPPORTED_DEFINITION, ) # Evaluate and cache each flag in the chain; members already cached are @@ -264,19 +333,21 @@ def evaluate_flag_dependency( if dep_flag_key in evaluation_cache: continue - dep_flag = flags_by_key.get(dep_flag_key) - if not dep_flag: - # Missing flag dependency - cannot evaluate locally - evaluation_cache[dep_flag_key] = None - raise InconclusiveMatchError( - f"Cannot evaluate flag dependency '{dep_flag_key}' - flag not found in local flags" - ) + dep_flag = flags_by_key[dep_flag_key] - # Check if the flag is active (same check as in client._compute_flag_locally) + # Same order as client._compute_flag_locally: an inactive flag is false + # whatever its experience continuity setting. if not dep_flag.get("active"): evaluation_cache[dep_flag_key] = False continue + if dep_flag.get("ensure_experience_continuity"): + evaluation_cache[dep_flag_key] = None + raise InconclusiveMatchError( + f"Cannot evaluate flag dependency '{dep_flag_key}' - flag has experience continuity enabled", + reason=UnresolvedFlagReason.UNRESOLVED_DEPENDENCY, + ) + # Recursively evaluate the dependency try: dep_flag_filters = dep_flag.get("filters") or {} @@ -303,11 +374,20 @@ def evaluate_flag_dependency( bucketing_value=dep_bucketing_value, ) evaluation_cache[dep_flag_key] = dep_result + except RequiresServerEvaluation as e: + # The dependent flag needs the server too, so its later conditions must + # not resolve it locally. Nothing is cached, so another filter on this + # dependency also raises this error. + raise RequiresServerEvaluation( + f"Cannot evaluate flag dependency '{dep_flag_key}': {e}", + reason=UnresolvedFlagReason.UNRESOLVED_DEPENDENCY, + ) from e except InconclusiveMatchError as e: # If we can't evaluate a dependency, store None and propagate the error evaluation_cache[dep_flag_key] = None raise InconclusiveMatchError( - f"Cannot evaluate flag dependency '{dep_flag_key}': {e}" + f"Cannot evaluate flag dependency '{dep_flag_key}': {e}", + reason=UnresolvedFlagReason.UNRESOLVED_DEPENDENCY, ) from e # The condition matches iff the referenced flag's value matches the expected @@ -315,8 +395,16 @@ def evaluate_flag_dependency( # definitive False, which must be allowed to match `expected_value=False`. actual_value = evaluation_cache.get(flag_key) if actual_value is None: + # A referenced flag that has no loaded definition or is missing from the + # chain is a definition problem, not an unresolved dependency. + reason = ( + UnresolvedFlagReason.UNRESOLVED_DEPENDENCY + if flag_key in flags_by_key and flag_key in evaluation_cache + else UnresolvedFlagReason.UNSUPPORTED_DEFINITION + ) raise InconclusiveMatchError( - f"Flag dependency '{flag_key}' was inconclusive or not evaluated" + f"Flag dependency '{flag_key}' was inconclusive or not evaluated", + reason=reason, ) return matches_dependency_value(expected_value, actual_value) @@ -371,7 +459,8 @@ def resolve_bucketing_value(flag, distinct_id, device_id=None): if bucketing_identifier == "device_id": if not device_id: raise InconclusiveMatchError( - "Flag requires device_id for bucketing but none was provided" + "Flag requires device_id for bucketing but none was provided", + reason=UnresolvedFlagReason.MISSING_CONTEXT, ) return device_id return distinct_id @@ -412,7 +501,7 @@ def match_feature_flag_properties( flag_conditions = flag_filters.get("groups") or [] flag_aggregation = flag_filters.get("aggregation_group_type_index") early_exit_enabled = flag_filters.get("early_exit") - is_inconclusive = False + inconclusive_reason: Optional[UnresolvedFlagReason] = None cohort_properties = cohort_properties or {} groups = groups or {} group_properties = group_properties or {} @@ -443,7 +532,9 @@ def match_feature_flag_properties( ) continue if group_name not in group_properties: - is_inconclusive = True + inconclusive_reason = _pick_unresolved_reason( + inconclusive_reason, UnresolvedFlagReason.MISSING_CONTEXT + ) continue effective_properties = group_properties[group_name] effective_bucketing = groups[group_name] @@ -480,20 +571,32 @@ def match_feature_flag_properties( # The condition's property filters (if any) matched and only the rollout check # failed, so re-evaluating later groups can't change the outcome. If an earlier # condition was inconclusive, stop here but preserve that result for fallback. - if is_inconclusive: + if inconclusive_reason is not None: break return False - except RequiresServerEvaluation: - # Static cohort or other missing server-side data - must fallback to API + except RequiresServerEvaluation as e: + # Static cohort or other missing server-side data - must fallback to API. + # The loop stops here, so a filter it did not reach can still carry a + # higher-precedence reason in the flag's definition. + try: + definition_reason = _get_definition_unresolved_reason( + flag, flags_by_key or {}, cohort_properties, group_type_mapping + ) + except Exception: + # A malformed definition must not stop the fallback to the server. + definition_reason = None + if definition_reason is not None: + e.reason = _pick_unresolved_reason(e.reason, definition_reason) raise - except InconclusiveMatchError: + except InconclusiveMatchError as e: # Evaluation error (bad regex, invalid date, missing property, etc.) # Track that we had an inconclusive match, but try other conditions - is_inconclusive = True + inconclusive_reason = _pick_unresolved_reason(inconclusive_reason, e.reason) - if is_inconclusive: + if inconclusive_reason is not None: raise InconclusiveMatchError( - "Can't determine if feature flag is enabled or not with given properties" + "Can't determine if feature flag is enabled or not with given properties", + reason=inconclusive_reason, ) # We can only return False when either all conditions are False, or @@ -501,6 +604,43 @@ def match_feature_flag_properties( return False +def _match_condition_filter( + prop, + properties, + cohort_properties, + flags_by_key, + evaluation_cache, + distinct_id, + *, + device_id, + property_matching_version: int, +) -> bool: + property_type = prop.get("type") + if property_type == "cohort": + return match_cohort( + prop, + properties, + cohort_properties, + flags_by_key, + evaluation_cache, + distinct_id, + device_id=device_id, + property_matching_version=property_matching_version, + ) + if property_type == "flag": + return evaluate_flag_dependency( + prop, + flags_by_key, + evaluation_cache, + distinct_id, + properties, + cohort_properties, + device_id=device_id, + property_matching_version=property_matching_version, + ) + return match_property(prop, properties, property_matching_version) + + def is_condition_match( feature_flag, distinct_id, @@ -516,10 +656,14 @@ def is_condition_match( ) -> ConditionMatch: rollout_percentage = condition.get("rollout_percentage") if len(condition.get("properties") or []) > 0: + # Filters in a condition have no defined order, so an inconclusive filter does + # not stop the loop: a later filter can still rule the condition out or carry a + # higher-precedence reason. + inconclusive_reason: Optional[UnresolvedFlagReason] = None + server_evaluation_error: Optional[RequiresServerEvaluation] = None for prop in condition.get("properties"): - property_type = prop.get("type") - if property_type == "cohort": - matches = match_cohort( + try: + matches = _match_condition_filter( prop, properties, cohort_properties, @@ -529,22 +673,41 @@ def is_condition_match( device_id=device_id, property_matching_version=property_matching_version, ) - elif property_type == "flag": - matches = evaluate_flag_dependency( - prop, - flags_by_key, - evaluation_cache, - distinct_id, - properties, - cohort_properties, - device_id=device_id, - property_matching_version=property_matching_version, + except RequiresServerEvaluation as e: + # Fall back to the server only when this is the first filter that could + # not be checked, as when the loop stopped there. After another + # inconclusive filter, later conditions must still be able to resolve + # the flag locally. + if inconclusive_reason is None: + server_evaluation_error = e + inconclusive_reason = _pick_unresolved_reason( + inconclusive_reason, e.reason ) - else: - matches = match_property(prop, properties, property_matching_version) + continue + except InconclusiveMatchError as e: + inconclusive_reason = _pick_unresolved_reason( + inconclusive_reason, e.reason + ) + continue + except Exception: + # After an inconclusive filter, an unexpected error ends the condition + # as inconclusive, so later conditions can still resolve the flag. + if inconclusive_reason is None: + raise + break if not matches: return ConditionMatch.NO_MATCH + # Re-raise the original error type: match_feature_flag_properties stops at it + # and falls back to the server instead of trying later conditions. + if server_evaluation_error is not None: + raise server_evaluation_error + if inconclusive_reason is not None: + raise InconclusiveMatchError( + f"Can't determine if a condition of flag {feature_flag.get('key')} matches", + reason=inconclusive_reason, + ) + if rollout_percentage is None: return ConditionMatch.MATCH @@ -567,10 +730,14 @@ def is_condition_match( ) -def _format_json_float(value: float) -> str: +# The value helpers below convert both filter values and values from the evaluation +# call. ``reason`` names the owner of the value: UNSUPPORTED_DEFINITION for a filter +# value, MISSING_CONTEXT for a value the call passed. +def _format_json_float(value: float, reason: UnresolvedFlagReason) -> str: if not math.isfinite(value): raise InconclusiveMatchError( - "Non-finite property values cannot be represented by the flags service" + "Non-finite property values cannot be represented by the flags service", + reason=reason, ) representation = repr(value) @@ -586,7 +753,7 @@ def _format_json_float(value: float) -> str: return f"{mantissa}e{sign}{exponent}" -def _json_value_to_string(value) -> str: +def _json_value_to_string(value, reason: UnresolvedFlagReason) -> str: if value is None: return "null" if isinstance(value, bool): @@ -596,33 +763,37 @@ def _json_value_to_string(value) -> str: if isinstance(value, int): return str(value) if isinstance(value, float): - return _format_json_float(value) + return _format_json_float(value, reason) if isinstance(value, list): - return "[" + ",".join(_json_value_to_string(item) for item in value) + "]" + return ( + "[" + ",".join(_json_value_to_string(item, reason) for item in value) + "]" + ) if isinstance(value, dict): if not all(isinstance(key, str) for key in value): raise InconclusiveMatchError( - "Property object keys must be strings for local evaluation" + "Property object keys must be strings for local evaluation", + reason=reason, ) items = ( - f"{json.dumps(key, ensure_ascii=False)}:{_json_value_to_string(value[key])}" + f"{json.dumps(key, ensure_ascii=False)}:{_json_value_to_string(value[key], reason)}" for key in sorted(value) ) return "{" + ",".join(items) + "}" raise InconclusiveMatchError( - f"Property value of type {type(value).__name__} is not JSON-compatible" + f"Property value of type {type(value).__name__} is not JSON-compatible", + reason=reason, ) -def _value_to_string(value) -> str: +def _value_to_string(value, reason: UnresolvedFlagReason) -> str: if isinstance(value, str): return value - return _json_value_to_string(value) + return _json_value_to_string(value, reason) -def _ascii_lower(value) -> str: - return _value_to_string(value).translate(_ASCII_LOWER_TRANSLATION) +def _ascii_lower(value, reason: UnresolvedFlagReason) -> str: + return _value_to_string(value, reason).translate(_ASCII_LOWER_TRANSLATION) def _is_truthy_or_falsy_property_value(value) -> bool: @@ -686,7 +857,10 @@ def _compile_regex(pattern: str) -> Optional[_RegexMetadata]: index = 0 def defer() -> NoReturn: - raise InconclusiveMatchError("Regex requires server evaluation") + raise InconclusiveMatchError( + "Regex requires server evaluation", + reason=UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) while index < len(pattern): character = pattern[index] @@ -824,7 +998,8 @@ def defer() -> NoReturn: return None except Exception as error: raise InconclusiveMatchError( - "Regex compilation requires server evaluation" + "Regex compilation requires server evaluation", + reason=UnresolvedFlagReason.UNSUPPORTED_DEFINITION, ) from error return _RegexMetadata( @@ -852,7 +1027,10 @@ def _match_regex(pattern: str, value: str) -> Optional[bool]: ) = metadata def defer() -> NoReturn: - raise InconclusiveMatchError("Regex requires server evaluation") + raise InconclusiveMatchError( + "Regex requires server evaluation", + reason=UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) # Tightening $ can expose backtracking on a final LF, including repeated alternatives. if uses_end_anchor and uses_quantifier_or_alternation and value.endswith("\n"): @@ -873,12 +1051,122 @@ def defer() -> NoReturn: return regex.search(value) is not None except Exception as error: raise InconclusiveMatchError( - "Regex execution requires server evaluation" + "Regex execution requires server evaluation", + reason=UnresolvedFlagReason.UNSUPPORTED_DEFINITION, ) from error +_TEXT_OPERATORS = ( + "icontains", + "not_icontains", + "starts_with", + "not_starts_with", + "ends_with", + "not_ends_with", +) + + +def _parse_numeric_filter(value) -> float: + parsed_value = _parse_numeric_value(value) + if parsed_value is None: + raise InconclusiveMatchError( + "The numeric condition is not a finite number", + reason=UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) + return parsed_value + + +def _parse_date_filter(value) -> datetime.datetime: + try: + parsed_date = relative_date_parse_for_feature_flag_matching(str(value)) + + if not parsed_date: + parsed_date = parse_datetime(str(value)) + parsed_date = convert_to_datetime_aware(parsed_date) + except Exception as e: + raise InconclusiveMatchError( + "The date set on the flag is not a valid format", + reason=UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) from e + + if not parsed_date: + raise InconclusiveMatchError( + "The date set on the flag is not a valid format", + reason=UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) + return parsed_date + + +@_lru_cache(maxsize=1024) +def _assert_date_filter_supported(value: str) -> None: + """Cache only valid filter values; lru_cache does not keep exceptions. Matching + still parses each time, so relative dates resolve against the current time.""" + _parse_date_filter(value) + + +def _parse_semver_filter(operator, value) -> tuple: + """Return the parsed version for a comparison operator, or the (lower, upper) + bounds for a range operator.""" + try: + if operator == "semver_tilde": + return _tilde_bounds(str(value)) + if operator == "semver_caret": + return _caret_bounds(str(value)) + if operator == "semver_wildcard": + return _wildcard_bounds(str(value)) + return parse_semver(value) + except (ValueError, TypeError): + raise InconclusiveMatchError( + f"Flag semver value '{value}' is not valid for operator {operator}", + reason=UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) + + +def _assert_filter_supported(property) -> None: + """Raise an unsupported_definition error when local evaluation can't use a + property filter, whatever the call passes: an unknown operator, or a filter + value the operator can't parse.""" + operator = property.get("operator") or "exact" + value = property.get("value") + if operator not in PROPERTY_OPERATORS: + raise InconclusiveMatchError( + f"Unknown operator {operator}", + reason=UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) + if operator in ("exact", "is_not"): + for item in value if isinstance(value, list) else [value]: + _value_to_string(item, UnresolvedFlagReason.UNSUPPORTED_DEFINITION) + elif operator in _TEXT_OPERATORS: + _value_to_string(value, UnresolvedFlagReason.UNSUPPORTED_DEFINITION) + elif operator in ("regex", "not_regex"): + pattern = str(value) + if pattern: + _compile_regex(pattern) + elif operator in NUMERIC_OPERATORS: + _parse_numeric_filter(value) + elif operator in DATE_OPERATORS: + _assert_date_filter_supported(str(value)) + elif operator in SEMVER_OPERATORS: + _parse_semver_filter(operator, value) + + def match_property( property, property_values, property_matching_version: int = 1 +) -> bool: + try: + return _match_property_value( + property, property_values, property_matching_version + ) + except InconclusiveMatchError as e: + # An unusable filter outranks missing context, so check the filter before + # blaming the context. + if e.reason == UnresolvedFlagReason.MISSING_CONTEXT: + _assert_filter_supported(property) + raise + + +def _match_property_value( + property, property_values, property_matching_version: int ) -> bool: # only looks for matches where key exists in override_property_values key = property.get("key") @@ -886,11 +1174,15 @@ def match_property( value = property.get("value") if operator not in PROPERTY_OPERATORS: - raise InconclusiveMatchError(f"Unknown operator {operator}") + raise InconclusiveMatchError( + f"Unknown operator {operator}", + reason=UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) if key not in property_values: raise InconclusiveMatchError( - "can't match properties without a given property value" + "can't match properties without a given property value", + reason=UnresolvedFlagReason.MISSING_CONTEXT, ) if operator in ("is_set", "is_not_set"): @@ -899,9 +1191,7 @@ def match_property( override_value = property_values[key] if operator in NUMERIC_OPERATORS: - parsed_value = _parse_numeric_value(value) - if parsed_value is None: - raise InconclusiveMatchError("The numeric condition is not a finite number") + parsed_value = _parse_numeric_filter(value) parsed_override = _parse_numeric_value(override_value) if parsed_override is None: return False @@ -924,7 +1214,9 @@ def match_property( if operator in ("exact", "is_not"): def compute_exact_match(value, override_value): - override_string = _value_to_string(override_value).lower() + override_string = _value_to_string( + override_value, UnresolvedFlagReason.MISSING_CONTEXT + ).lower() # Empty filters retain recursive truthiness in both matching modes. if value == []: return _is_truthy_property_value(override_value) @@ -937,25 +1229,28 @@ def compute_exact_match(value, override_value): if isinstance(value, list): return override_string in [ - _value_to_string(candidate).lower() for candidate in value + _value_to_string( + candidate, UnresolvedFlagReason.UNSUPPORTED_DEFINITION + ).lower() + for candidate in value ] - return _value_to_string(value).lower() == override_string + return ( + _value_to_string( + value, UnresolvedFlagReason.UNSUPPORTED_DEFINITION + ).lower() + == override_string + ) if operator == "exact": return compute_exact_match(value, override_value) else: return not compute_exact_match(value, override_value) - if operator in ( - "icontains", - "not_icontains", - "starts_with", - "not_starts_with", - "ends_with", - "not_ends_with", - ): - property_string = _ascii_lower(override_value) - filter_string = _ascii_lower(value) + if operator in _TEXT_OPERATORS: + property_string = _ascii_lower( + override_value, UnresolvedFlagReason.MISSING_CONTEXT + ) + filter_string = _ascii_lower(value, UnresolvedFlagReason.UNSUPPORTED_DEFINITION) if operator in ("icontains", "not_icontains"): matched = filter_string in property_string @@ -975,22 +1270,8 @@ def compute_exact_match(value, override_value): return False return not regex_match if operator == "not_regex" else regex_match - if operator in ["is_date_before", "is_date_after"]: - try: - parsed_date = relative_date_parse_for_feature_flag_matching(str(value)) - - if not parsed_date: - parsed_date = parse_datetime(str(value)) - parsed_date = convert_to_datetime_aware(parsed_date) - except Exception as e: - raise InconclusiveMatchError( - "The date set on the flag is not a valid format" - ) from e - - if not parsed_date: - raise InconclusiveMatchError( - "The date set on the flag is not a valid format" - ) + if operator in DATE_OPERATORS: + parsed_date = _parse_date_filter(value) if isinstance(override_value, datetime.datetime): override_date = convert_to_datetime_aware(override_value) @@ -1012,71 +1293,51 @@ def compute_exact_match(value, override_value): else: return override_date > parsed_date except Exception: - raise InconclusiveMatchError("The date provided is not a valid format") + raise InconclusiveMatchError( + "The date provided is not a valid format", + reason=UnresolvedFlagReason.MISSING_CONTEXT, + ) else: raise InconclusiveMatchError( - "The date provided must be a string or date object" + "The date provided must be a string or date object", + reason=UnresolvedFlagReason.MISSING_CONTEXT, ) if operator in SEMVER_OPERATORS: + flag_semver = _parse_semver_filter(operator, value) try: override_parsed = parse_semver(override_value) except (ValueError, TypeError): raise InconclusiveMatchError( - f"Person property value '{override_value}' is not a valid semver" + f"Person property value '{override_value}' is not a valid semver", + reason=UnresolvedFlagReason.MISSING_CONTEXT, ) - if operator in SEMVER_COMPARISON_OPERATORS: - try: - flag_parsed = parse_semver(value) - except (ValueError, TypeError): - raise InconclusiveMatchError( - f"Flag semver value '{value}' is not a valid semver" - ) - - if operator == "semver_eq": - return override_parsed == flag_parsed - elif operator == "semver_neq": - return override_parsed != flag_parsed - elif operator == "semver_gt": - return override_parsed > flag_parsed - elif operator == "semver_gte": - return override_parsed >= flag_parsed - elif operator == "semver_lt": - return override_parsed < flag_parsed - elif operator == "semver_lte": - return override_parsed <= flag_parsed - - elif operator == "semver_tilde": - try: - lower, upper = _tilde_bounds(str(value)) - except (ValueError, TypeError): - raise InconclusiveMatchError( - f"Flag semver value '{value}' is not valid for tilde operator" - ) - return lower <= override_parsed < upper - - elif operator == "semver_caret": - try: - lower, upper = _caret_bounds(str(value)) - except (ValueError, TypeError): - raise InconclusiveMatchError( - f"Flag semver value '{value}' is not valid for caret operator" - ) - return lower <= override_parsed < upper - - elif operator == "semver_wildcard": - try: - lower, upper = _wildcard_bounds(str(value)) - except (ValueError, TypeError): - raise InconclusiveMatchError( - f"Flag semver value '{value}' is not valid for wildcard operator" - ) + if operator in SEMVER_RANGE_OPERATORS: + lower, upper = flag_semver return lower <= override_parsed < upper + if operator == "semver_eq": + return override_parsed == flag_semver + if operator == "semver_neq": + return override_parsed != flag_semver + if operator == "semver_gt": + return override_parsed > flag_semver + if operator == "semver_gte": + return override_parsed >= flag_semver + if operator == "semver_lt": + return override_parsed < flag_semver + if operator == "semver_lte": + return override_parsed <= flag_semver # Unreachable: all operators in PROPERTY_OPERATORS are handled above, # and unknown operators are rejected at the top of this function. - raise InconclusiveMatchError(f"Operator {operator} {_UNHANDLED_OPERATOR_MESSAGE}") + raise InconclusiveMatchError( + f"Operator {operator} {_UNHANDLED_OPERATOR_MESSAGE}", + reason=UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) + + +_COHORT_OPERATORS = ("exact", "in", "not_in") def match_cohort( @@ -1104,6 +1365,15 @@ def match_cohort( f"cohort {cohort_id} not found in local cohorts - likely a static cohort that requires server evaluation" ) + # Check the operator before the members, so an unsupported operator outranks a + # member that is only missing context. + operator = property.get("operator") or "exact" + if operator not in _COHORT_OPERATORS: + raise InconclusiveMatchError( + f"Unsupported cohort operator: {operator}", + reason=UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ) + property_group = cohort_properties[cohort_id] matches = match_property_group( property_group, @@ -1115,13 +1385,12 @@ def match_cohort( device_id=device_id, property_matching_version=property_matching_version, ) + return not matches if operator == "not_in" else matches - operator = property.get("operator") or "exact" - if operator in ("exact", "in"): - return matches - if operator == "not_in": - return not matches - raise InconclusiveMatchError(f"Unsupported cohort operator: {operator}") + +def _is_nested_property_group(prop) -> bool: + """Whether a cohort group entry is itself a property group, not a filter.""" + return prop == {} or "values" in prop or prop.get("type") in ("AND", "OR") def match_property_group( @@ -1151,7 +1420,7 @@ def match_property_group( return True decisive_result = None - error_matching_locally = False + inconclusive_reason: Optional[UnresolvedFlagReason] = None for prop in properties: try: @@ -1160,7 +1429,7 @@ def match_property_group( "Cohort property group entry must be an object" ) - if prop == {} or "values" in prop or prop.get("type") in ("AND", "OR"): + if _is_nested_property_group(prop): matches = match_property_group( prop, property_values, @@ -1213,19 +1482,101 @@ def match_property_group( raise except InconclusiveMatchError as e: log.debug(f"Failed to compute property {prop} locally: {e}") - error_matching_locally = True + inconclusive_reason = _pick_unresolved_reason(inconclusive_reason, e.reason) if decisive_result is not None: return decisive_result - if error_matching_locally: + if inconclusive_reason is not None: raise InconclusiveMatchError( - "Can't match cohort without a given cohort property value" + "Can't match cohort without a given cohort property value", + reason=inconclusive_reason, ) # AND: every entry matched. OR: none matched. return is_and +def _get_definition_unresolved_reason( + flag, flags_by_key, cohort_properties, group_type_mapping +) -> Optional[UnresolvedFlagReason]: + """Return the unresolved reason that comes from the flag's definition alone, so + it is known when definitions load. Matches the reason local evaluation reports + for the same cause.""" + if not flag.get("active"): + return None + if flag.get("ensure_experience_continuity"): + return UnresolvedFlagReason.EXPERIENCE_CONTINUITY + flag_filters = flag.get("filters") or {} + aggregation = flag_filters.get("aggregation_group_type_index") + if aggregation is not None and not group_type_mapping.get(str(aggregation)): + return UnresolvedFlagReason.UNSUPPORTED_DEFINITION + for condition in flag_filters.get("groups") or []: + for prop in condition.get("properties") or []: + if _is_unsupported_property(prop, flags_by_key, cohort_properties, set()): + return UnresolvedFlagReason.UNSUPPORTED_DEFINITION + return None + + +def _is_unsupported_property( + prop, flags_by_key, cohort_properties, visited_cohort_ids +) -> bool: + property_type = prop.get("type") + if property_type == "flag": + # A malformed condition shape is a definitive local no-match, not a gap. + if _flag_dependency_shape_problem(prop) is not None: + return False + dependency_chain = prop.get("dependency_chain") + if not isinstance(dependency_chain, list) or not dependency_chain: + return True + return any(not flags_by_key.get(key) for key in dependency_chain) + if property_type != "cohort": + try: + _assert_filter_supported(prop) + except InconclusiveMatchError: + return True + return False + cohort_id = str(prop.get("value")) + if cohort_id not in cohort_properties: + return True + if (prop.get("operator") or "exact") not in _COHORT_OPERATORS: + return True + if cohort_id in visited_cohort_ids: + return False + visited_cohort_ids.add(cohort_id) + return _is_unsupported_property_group( + cohort_properties[cohort_id], + flags_by_key, + cohort_properties, + visited_cohort_ids, + ) + + +def _is_unsupported_property_group( + property_group, flags_by_key, cohort_properties, visited_cohort_ids +) -> bool: + if property_group == {}: + return False + if not isinstance(property_group, dict): + return True + values = property_group.get("values") + if property_group.get("type") not in ("AND", "OR") or not isinstance(values, list): + return True + for prop in values: + if not isinstance(prop, dict): + return True + if _is_nested_property_group(prop): + unsupported = _is_unsupported_property_group( + prop, flags_by_key, cohort_properties, visited_cohort_ids + ) + else: + unsupported = _is_unsupported_property( + prop, flags_by_key, cohort_properties, visited_cohort_ids + ) + if unsupported: + return True + return False + + def parse_datetime(value: str) -> datetime.datetime: text = value.strip() if text.endswith("Z"): diff --git a/posthog/test/test_evaluate_flags.py b/posthog/test/test_evaluate_flags.py index 32a63ab27..25ca18e51 100644 --- a/posthog/test/test_evaluate_flags.py +++ b/posthog/test/test_evaluate_flags.py @@ -1,3 +1,4 @@ +import logging import unittest import warnings from unittest import mock @@ -6,7 +7,9 @@ from posthog.client import Client from posthog.feature_flag_evaluations import FeatureFlagEvaluations +from posthog.request import GetResponse from posthog.test.test_utils import FAKE_TEST_API_KEY +from posthog.types import UnresolvedFlagReason def _flags_response_fixture(): @@ -552,6 +555,702 @@ def test_context_device_id_is_used_for_local_evaluation( patch_flags.assert_not_called() +def _always_on_flag(key, **extra): + return { + "id": 1, + "key": key, + "active": True, + "filters": {"groups": [{"properties": [], "rollout_percentage": 100}]}, + **extra, + } + + +def _flag_with(key, *properties, **extra): + flag = _always_on_flag(key, **extra) + flag["filters"]["groups"][0]["properties"] = list(properties) + return flag + + +def _depends_on(key): + return { + "key": key, + "type": "flag", + "value": True, + "operator": "flag_evaluates_to", + "dependency_chain": [key], + } + + +_PLAN_IS_PRO = {"key": "plan", "operator": "exact", "value": "pro", "type": "person"} +_STATIC_COHORT = {"key": "id", "type": "cohort", "value": 42} + + +def _flag_called_properties(patch_capture): + return { + c[1]["properties"]["$feature_flag"]: c[1]["properties"] + for c in patch_capture.call_args_list + if c[0] and c[0][0] == "$feature_flag_called" + } + + +class TestEvaluateFlagsUnresolved(unittest.TestCase): + """Mirrors the ``@unresolved_flags_capable`` scenarios of the evaluate-flags spec.""" + + def setUp(self): + self.client = Client(FAKE_TEST_API_KEY, secret_key="test") + self.client.feature_flags = [ + _always_on_flag("beta-ui"), + _always_on_flag("checkout", ensure_experience_continuity=True), + ] + + def tearDown(self): + self.client.shutdown() + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_local_only_reports_experience_continuity_flag( + self, patch_capture, patch_flags + ): + flags = self.client.evaluate_flags("user-123", only_evaluate_locally=True) + + self.assertEqual(flags.keys, ["beta-ui"]) + self.assertEqual( + dict(flags.unresolved_flags), + {"checkout": UnresolvedFlagReason.EXPERIENCE_CONTINUITY}, + ) + self.assertEqual(flags.unresolved_flags["checkout"], "experience_continuity") + patch_flags.assert_not_called() + self.assertEqual(_flag_called_properties(patch_capture), {}) + self.assertEqual(flags.only_accessed().keys, []) + self.assertEqual(dict(flags.only(["beta-ui", "checkout"]).unresolved_flags), {}) + self.assertIs(flags.get_flag("beta-ui"), True) + + @mock.patch("posthog.client.flags") + def test_unresolved_flags_is_read_only(self, _patch_flags): + flags = self.client.evaluate_flags("user-123", only_evaluate_locally=True) + + with self.assertRaises(TypeError): + flags.unresolved_flags["beta-ui"] = UnresolvedFlagReason.MISSING_CONTEXT # type: ignore[index] + + @parameterized.expand( + [ + ( + "missing_person_property", + _flag_with("checkout", _PLAN_IS_PRO), + {}, + UnresolvedFlagReason.MISSING_CONTEXT, + ), + ( + "device_bucketed_without_device_id", + _always_on_flag("checkout", bucketing_identifier="device_id"), + {}, + UnresolvedFlagReason.MISSING_CONTEXT, + ), + ( + "unrecognized_operator", + _flag_with( + "checkout", + { + "key": "plan", + "operator": "bogus", + "value": "x", + "type": "person", + }, + ), + {"person_properties": {"plan": "enterprise"}}, + UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ), + ( + "static_cohort", + _flag_with("checkout", _STATIC_COHORT), + {}, + UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ), + ( + "static_cohort_and_missing_property", + _flag_with("checkout", _STATIC_COHORT, _PLAN_IS_PRO), + {}, + UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ), + ( + "missing_property_and_static_cohort", + _flag_with("checkout", _PLAN_IS_PRO, _STATIC_COHORT), + {}, + UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ), + ( + "device_bucketed_with_static_cohort", + _flag_with( + "checkout", _STATIC_COHORT, bucketing_identifier="device_id" + ), + {}, + UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ), + ] + ) + @mock.patch("posthog.client.flags") + def test_each_inconclusive_cause_reports_its_reason( + self, _name, checkout, call_kwargs, reason, patch_flags + ): + self.client.feature_flags = [ + {**checkout, "ensure_experience_continuity": False} + ] + + flags = self.client.evaluate_flags( + "user-123", only_evaluate_locally=True, **call_kwargs + ) + + self.assertNotIn("checkout", flags.keys) + self.assertEqual(dict(flags.unresolved_flags), {"checkout": reason}) + patch_flags.assert_not_called() + + @mock.patch("posthog.client.flags") + def test_flag_blocked_by_unresolved_dependency_reports_the_dependency( + self, _patch_flags + ): + self.client.feature_flags = [ + _always_on_flag("checkout", ensure_experience_continuity=True), + _flag_with("beta-ui", _depends_on("checkout")), + ] + + flags = self.client.evaluate_flags( + "user-123", flag_keys=["beta-ui"], only_evaluate_locally=True + ) + + self.assertNotIn("beta-ui", flags.keys) + self.assertEqual( + dict(flags.unresolved_flags), + {"beta-ui": UnresolvedFlagReason.UNRESOLVED_DEPENDENCY}, + ) + + @mock.patch("posthog.client.flags") + def test_dependency_in_scope_carries_its_own_reason(self, _patch_flags): + self.client.feature_flags = [ + _flag_with("checkout", _PLAN_IS_PRO), + _flag_with("beta-ui", _depends_on("checkout")), + ] + + flags = self.client.evaluate_flags("user-123", only_evaluate_locally=True) + + self.assertEqual( + dict(flags.unresolved_flags), + { + "checkout": UnresolvedFlagReason.MISSING_CONTEXT, + "beta-ui": UnresolvedFlagReason.UNRESOLVED_DEPENDENCY, + }, + ) + + @mock.patch("posthog.client.flags") + def test_flag_depending_on_flag_without_definition_is_unsupported_definition( + self, _patch_flags + ): + self.client.feature_flags = [_flag_with("beta-ui", _depends_on("checkout"))] + + flags = self.client.evaluate_flags( + "user-123", flag_keys=["beta-ui"], only_evaluate_locally=True + ) + + self.assertNotIn("beta-ui", flags.keys) + self.assertEqual( + dict(flags.unresolved_flags), + {"beta-ui": UnresolvedFlagReason.UNSUPPORTED_DEFINITION}, + ) + + @staticmethod + def _target_depending_on(dep_property): + """``target`` is ``a`` when ``dep`` is true, and ``b`` for everyone else.""" + target = _always_on_flag("target") + target["filters"] = { + "groups": [ + { + "properties": [_depends_on("dep")], + "rollout_percentage": 100, + "variant": "a", + }, + {"properties": [], "rollout_percentage": 100, "variant": "b"}, + ], + "multivariate": { + "variants": [ + {"key": "a", "rollout_percentage": 50}, + {"key": "b", "rollout_percentage": 50}, + ] + }, + } + return [_flag_with("dep", dep_property), target] + + @parameterized.expand([("all_flags", None), ("target_only", ["target"])]) + @mock.patch("posthog.client.flags") + def test_dependency_that_needs_the_server_falls_back_to_the_server( + self, _name, flag_keys, patch_flags + ): + self.client.feature_flags = self._target_depending_on( + {"key": "id", "type": "cohort", "value": 999} + ) + patch_flags.return_value = { + "flags": {"target": {"enabled": True, "variant": "a"}} + } + + flags = self.client.evaluate_flags("user-123", flag_keys=flag_keys) + + self.assertEqual(flags.get_flag("target"), "a") + patch_flags.assert_called_once() + + @parameterized.expand( + [ + ("not_an_object", "not-a-filter"), + ("dependency_without_key", {**_depends_on("dep"), "key": None}), + ] + ) + @mock.patch("posthog.client.flags") + def test_dependency_that_needs_the_server_falls_back_despite_malformed_later_condition( + self, _name, malformed_filter, patch_flags + ): + dep, target = self._target_depending_on( + {"key": "id", "type": "cohort", "value": 999} + ) + target["filters"]["groups"][1]["properties"] = [malformed_filter] + self.client.feature_flags = [dep, target] + patch_flags.return_value = { + "flags": {"target": {"enabled": True, "variant": "a"}} + } + + with mock.patch.object(self.client.log, "exception") as patch_exception: + flags = self.client.evaluate_flags("user-123", flag_keys=["target"]) + + self.assertEqual(flags.get_flag("target"), "a") + patch_flags.assert_called_once() + patch_exception.assert_not_called() + + @mock.patch("posthog.client.flags") + def test_dependency_that_needs_the_server_leaves_flag_unresolved_locally( + self, patch_flags + ): + self.client.feature_flags = self._target_depending_on( + {"key": "id", "type": "cohort", "value": 999} + ) + + flags = self.client.evaluate_flags("user-123", only_evaluate_locally=True) + + self.assertNotIn("target", flags.keys) + self.assertEqual( + dict(flags.unresolved_flags), + { + "dep": UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + "target": UnresolvedFlagReason.UNRESOLVED_DEPENDENCY, + }, + ) + patch_flags.assert_not_called() + + @mock.patch("posthog.client.flags") + def test_dependency_resolved_locally_picks_its_condition(self, patch_flags): + self.client.feature_flags = self._target_depending_on(_PLAN_IS_PRO) + + flags = self.client.evaluate_flags( + "user-123", person_properties={"plan": "pro"} + ) + + self.assertEqual(flags.get_flag("target"), "a") + patch_flags.assert_not_called() + + @mock.patch("posthog.client.flags") + def test_flag_resolved_by_remote_fallback_is_not_unresolved(self, patch_flags): + patch_flags.return_value = { + "flags": {"checkout": {"enabled": True, "variant": None}} + } + + flags = self.client.evaluate_flags("user-123") + + self.assertIs(flags.get_flag("checkout"), True) + self.assertEqual(sorted(flags.keys), ["beta-ui", "checkout"]) + self.assertEqual(dict(flags.unresolved_flags), {}) + patch_flags.assert_called_once() + + @parameterized.expand( + [ + ( + "request_fails", + Exception("flags request failed"), + {}, + "local_evaluation_inconclusive", + ), + ("returns_no_flags", None, {}, "local_evaluation_inconclusive"), + ( + "errors_while_computing", + None, + {"errorsWhileComputingFlags": True}, + "errors_while_computing_flags,local_evaluation_inconclusive", + ), + ] + ) + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_flag_left_without_remote_value_is_unresolved( + self, + _name, + side_effect, + extra_response, + expected_error, + patch_capture, + patch_flags, + ): + patch_flags.side_effect = side_effect + patch_flags.return_value = {"flags": {}, **extra_response} + + flags = self.client.evaluate_flags("user-123") + + self.assertNotIn("checkout", flags.keys) + self.assertEqual( + dict(flags.unresolved_flags), + {"checkout": UnresolvedFlagReason.EXPERIENCE_CONTINUITY}, + ) + flags.is_enabled("checkout") + properties = _flag_called_properties(patch_capture)["checkout"] + self.assertEqual(properties["$feature_flag_error"], expected_error) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_key_without_local_definition_is_missing_not_unresolved( + self, patch_capture, patch_flags + ): + self.client.feature_flags = [_always_on_flag("beta-ui")] + patch_flags.return_value = {"flags": {}} + + flags = self.client.evaluate_flags( + "user-123", flag_keys=["beta-ui", "typo-flag"] + ) + flags.is_enabled("typo-flag") + + self.assertEqual(dict(flags.unresolved_flags), {}) + properties = _flag_called_properties(patch_capture)["typo-flag"] + self.assertEqual(properties["$feature_flag_error"], "flag_missing") + + @mock.patch("posthog.client.flags") + def test_flag_outside_requested_keys_is_not_unresolved(self, patch_flags): + flags = self.client.evaluate_flags("user-123", flag_keys=["beta-ui"]) + + self.assertEqual(flags.keys, ["beta-ui"]) + self.assertTrue(flags.is_enabled("beta-ui")) + self.assertEqual(dict(flags.unresolved_flags), {}) + patch_flags.assert_not_called() + + @mock.patch("posthog.client.flags") + def test_inactive_flag_is_false_not_unresolved(self, patch_flags): + self.client.feature_flags = [ + _always_on_flag("checkout", active=False, ensure_experience_continuity=True) + ] + + flags = self.client.evaluate_flags("user-123", only_evaluate_locally=True) + + self.assertEqual(flags.keys, ["checkout"]) + self.assertIs(flags.get_flag("checkout"), False) + self.assertEqual(dict(flags.unresolved_flags), {}) + patch_flags.assert_not_called() + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_reading_remote_resolved_flag_reports_no_error( + self, patch_capture, patch_flags + ): + patch_flags.return_value = { + "flags": {"checkout": {"enabled": True, "variant": None}} + } + + flags = self.client.evaluate_flags("user-123") + + self.assertTrue(flags.is_enabled("checkout")) + properties = _flag_called_properties(patch_capture)["checkout"] + self.assertIs(properties["$feature_flag_response"], True) + self.assertNotIn("$feature_flag_error", properties) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_reading_unresolved_flag_reports_local_evaluation_inconclusive( + self, patch_capture, _patch_flags + ): + flags = self.client.evaluate_flags("user-123", only_evaluate_locally=True) + + self.assertFalse(flags.is_enabled("checkout")) + properties = _flag_called_properties(patch_capture)["checkout"] + self.assertEqual( + properties["$feature_flag_error"], "local_evaluation_inconclusive" + ) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_filtered_snapshots_carry_no_unresolved_flags( + self, patch_capture, _patch_flags + ): + flags = self.client.evaluate_flags("user-123", only_evaluate_locally=True) + flags.is_enabled("beta-ui") + + for filtered in (flags.only(["beta-ui"]), flags.only_accessed()): + self.assertEqual(dict(filtered.unresolved_flags), {}) + filtered.get_flag("checkout") + properties = _flag_called_properties(patch_capture)["checkout"] + self.assertEqual(properties["$feature_flag_error"], "flag_missing") + self.assertEqual( + dict(flags.unresolved_flags), + {"checkout": UnresolvedFlagReason.EXPERIENCE_CONTINUITY}, + ) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_unexpected_error_is_missing_not_unresolved( + self, patch_capture, _patch_flags + ): + broken = _always_on_flag("checkout") + broken["filters"]["groups"][0]["rollout_percentage"] = "abc" + self.client.feature_flags = [broken] + + flags = self.client.evaluate_flags("user-123", only_evaluate_locally=True) + flags.is_enabled("checkout") + + self.assertEqual(dict(flags.unresolved_flags), {}) + properties = _flag_called_properties(patch_capture)["checkout"] + self.assertEqual(properties["$feature_flag_error"], "flag_missing") + + @mock.patch("posthog.client.flags") + def test_filter_that_does_not_match_resolves_flag_locally(self, patch_flags): + self.client.feature_flags = [ + _flag_with( + "checkout", + _STATIC_COHORT, + {"key": "plan", "operator": "exact", "value": "free"}, + ) + ] + + flags = self.client.evaluate_flags( + "user-123", person_properties={"plan": "pro"} + ) + + self.assertIs(flags.get_flag("checkout"), False) + self.assertEqual(dict(flags.unresolved_flags), {}) + patch_flags.assert_not_called() + + +class TestUnresolvableFlagWarning(unittest.TestCase): + """Mirrors the ``@unresolvable_flag_warning_capable`` scenarios of the evaluate-flags spec.""" + + def _client(self, **kwargs): + client = Client( + FAKE_TEST_API_KEY, + secret_key="test", + enable_local_evaluation=False, + send=False, + **kwargs, + ) + self.addCleanup(client.shutdown) + return client + + @staticmethod + def _definitions(*flags): + return {"flags": list(flags), "group_type_mapping": {}, "cohorts": {}} + + @staticmethod + def _refresh(client, response): + with mock.patch("posthog.client.get", return_value=response): + client._load_feature_flags() + + def _refresh_with(self, client, *flags): + self._refresh( + client, + GetResponse(data=self._definitions(*flags), etag=None, not_modified=False), + ) + + @staticmethod + def _warnings(logs, key, reason): + return [ + record + for record in logs.records + if record.levelno == logging.WARNING + and f"(reason: {reason})" in record.getMessage() + and key in record.getMessage() + ] + + @parameterized.expand( + [("unchanged_definitions",), ("not_modified",), ("shared_cache_load",)] + ) + @mock.patch("posthog.client.flags") + def test_loading_never_resolvable_definition_logs_one_warning( + self, refresh_result, patch_flags + ): + definitions = self._definitions( + _always_on_flag("checkout", ensure_experience_continuity=True), + _always_on_flag("beta-ui"), + ) + cache_provider = mock.Mock() + cache_provider.should_fetch_flag_definitions.return_value = False + cache_provider.get_flag_definitions.return_value = definitions + client = self._client( + flag_definition_cache_provider=( + cache_provider if refresh_result == "shared_cache_load" else None + ) + ) + self.assertFalse(client.debug) + + with self.assertLogs("posthog", level=logging.DEBUG) as logs: + if refresh_result == "shared_cache_load": + client._load_feature_flags() + client._load_feature_flags() + else: + self._refresh( + client, + GetResponse(data=definitions, etag="etag-1", not_modified=False), + ) + self._refresh( + client, + GetResponse(data=None, etag="etag-1", not_modified=True) + if refresh_result == "not_modified" + else GetResponse( + data=definitions, etag="etag-2", not_modified=False + ), + ) + + warnings_logged = self._warnings(logs, "checkout", "experience_continuity") + self.assertEqual(len(warnings_logged), 1) + self.assertNotIn("beta-ui", warnings_logged[0].getMessage()) + self.assertTrue(client.log.isEnabledFor(logging.WARNING)) + patch_flags.assert_not_called() + + @mock.patch("posthog.client.flags") + def test_changed_definition_logs_warning_again(self, patch_flags): + client = self._client() + checkout = _always_on_flag("checkout", ensure_experience_continuity=True) + + with self.assertLogs("posthog", level=logging.WARNING) as logs: + self._refresh_with(client, checkout) + self._refresh_with(client, {**checkout, "id": 2}) + + self.assertEqual( + len(self._warnings(logs, "checkout", "experience_continuity")), 2 + ) + patch_flags.assert_not_called() + + def test_change_to_another_flag_does_not_repeat_warning(self): + client = self._client() + checkout = _always_on_flag("checkout", ensure_experience_continuity=True) + + with self.assertLogs("posthog", level=logging.WARNING) as logs: + self._refresh_with(client, checkout, _always_on_flag("beta-ui")) + self._refresh_with(client, checkout, _always_on_flag("beta-ui", id=2)) + + self.assertEqual( + len(self._warnings(logs, "checkout", "experience_continuity")), 1 + ) + + @parameterized.expand( + [ + ("static_cohort", _flag_with("checkout", _STATIC_COHORT)), + ( + "unknown_operator", + _flag_with( + "checkout", {"key": "plan", "operator": "bogus", "value": "x"} + ), + ), + ( + "malformed_value", + _flag_with( + "checkout", + {"key": "since", "operator": "is_date_after", "value": "x"}, + ), + ), + ("missing_dependency", _flag_with("checkout", _depends_on("missing"))), + ( + "circular_dependency", + _flag_with( + "checkout", {**_depends_on("checkout"), "dependency_chain": []} + ), + ), + ] + ) + def test_unsupported_definition_logs_warning(self, _name, checkout): + client = self._client() + + with self.assertLogs("posthog", level=logging.WARNING) as logs: + self._refresh_with(client, checkout) + self._refresh_with(client, checkout) + + self.assertEqual( + len(self._warnings(logs, "checkout", "unsupported_definition")), 1 + ) + + def test_does_not_warn_for_flags_that_depend_on_the_call(self): + client = self._client() + + with self.assertLogs("posthog", level=logging.WARNING) as logs: + self._refresh_with( + client, + _always_on_flag("continuity", ensure_experience_continuity=True), + _always_on_flag( + "inactive", active=False, ensure_experience_continuity=True + ), + _flag_with("needs-plan", _PLAN_IS_PRO), + _flag_with("dependent", _depends_on("continuity")), + ) + + messages = " ".join(record.getMessage() for record in logs.records) + self.assertIn("continuity", messages) + for key in ("inactive", "needs-plan", "dependent"): + self.assertNotIn(key, messages) + + def test_malformed_flag_does_not_block_other_warnings(self): + client = self._client() + broken = {"key": "broken", "active": True, "filters": {"groups": [None]}} + + with self.assertLogs("posthog", level=logging.WARNING) as logs: + self._refresh_with( + client, + broken, + _always_on_flag("checkout", ensure_experience_continuity=True), + ) + + self.assertEqual( + len(self._warnings(logs, "checkout", "experience_continuity")), 1 + ) + + def test_removed_flag_is_forgotten_and_warns_again_when_it_returns(self): + client = self._client() + checkout = _always_on_flag("checkout", ensure_experience_continuity=True) + + with self.assertLogs("posthog", level=logging.WARNING) as logs: + self._refresh_with(client, checkout) + self._refresh_with(client, _always_on_flag("beta-ui")) + self.assertEqual(client._warned_unresolvable_flags, {}) + self._refresh_with(client, checkout) + + self.assertEqual( + len(self._warnings(logs, "checkout", "experience_continuity")), 2 + ) + + def test_classifies_and_logs_outside_the_publication_lock(self): + client = self._client() + lock = client._flag_definition_publication_lock + lock_held = [] + classify = Client._classify_unresolvable_flags + + def record_lock_and_classify(snapshot): + lock_held.append(lock._is_owned()) + return classify(snapshot) + + with ( + mock.patch.object( + Client, + "_classify_unresolvable_flags", + side_effect=record_lock_and_classify, + ), + mock.patch.object( + client.log, + "warning", + side_effect=lambda *args, **kwargs: lock_held.append(lock._is_owned()), + ), + ): + self._refresh_with( + client, _always_on_flag("checkout", ensure_experience_continuity=True) + ) + + self.assertEqual(lock_held, [False, False]) + + class TestEvaluateFlagsFiltering(unittest.TestCase): def setUp(self): self.client = Client(FAKE_TEST_API_KEY) diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 2c430336d..14a1ed18e 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -19,14 +19,17 @@ _UNHANDLED_OPERATOR_MESSAGE, InconclusiveMatchError, RequiresServerEvaluation, + _get_definition_unresolved_reason, + _pick_unresolved_reason, match_cohort, + match_feature_flag_properties, match_property, match_property_group, parse_datetime, relative_date_parse_for_feature_flag_matching, ) from posthog.request import APIError, GetResponse -from posthog.types import FeatureFlagEvaluationRuntime +from posthog.types import FeatureFlagEvaluationRuntime, UnresolvedFlagReason from posthog.test.test_utils import FAKE_TEST_API_KEY from posthog.utils import FlagCache @@ -2006,14 +2009,15 @@ def test_feature_flags_with_flag_dependencies(self, patch_flags, mock_log): self.assertEqual(patch_flags.call_count, 1) patch_load.assert_not_called() - # Test with email that doesn't match (should also fall back to remote due to missing dependency) + # An email that doesn't match rules the condition out whatever the missing + # dependency would evaluate to, so the flag resolves locally. feature_flag_match = client.get_feature_flag( "flag-with-dependencies", "test-user-2", person_properties={"email": "test@other.com"}, ) - self.assertIsNone(feature_flag_match) - self.assertEqual(patch_flags.call_count, 2) # Called twice now + self.assertIs(feature_flag_match, False) + self.assertEqual(patch_flags.call_count, 1) patch_load.assert_not_called() @mock.patch("posthog.client.flags") @@ -5367,6 +5371,153 @@ def property(self, key, value, operator=None): return result + @parameterized.expand( + [ + ("unknown_operator", "x", "bogus", {"key": "x"}, "UNSUPPORTED_DEFINITION"), + ("missing_property", "x", "exact", {}, "MISSING_CONTEXT"), + ( + "invalid_flag_date", + "not-a-date", + "is_date_before", + {"key": "2024-01-01"}, + "UNSUPPORTED_DEFINITION", + ), + ( + "invalid_property_date", + "2024-01-01", + "is_date_before", + {"key": "not-a-date"}, + "MISSING_CONTEXT", + ), + ( + "invalid_flag_semver", + "x.y", + "semver_gt", + {"key": "1.2.3"}, + "UNSUPPORTED_DEFINITION", + ), + ( + "invalid_property_semver", + "1.2.3", + "semver_gt", + {"key": "x.y"}, + "MISSING_CONTEXT", + ), + ( + "invalid_flag_and_property_semver", + "x.y", + "semver_gt", + {"key": "a.b"}, + "UNSUPPORTED_DEFINITION", + ), + ( + "invalid_flag_semver_and_missing_property", + "x.y", + "semver_gt", + {}, + "UNSUPPORTED_DEFINITION", + ), + ( + "invalid_flag_wildcard_and_property", + "1x.*", + "semver_wildcard", + {"key": "a.b"}, + "UNSUPPORTED_DEFINITION", + ), + ( + "boolean_flag_date", + True, + "is_date_after", + {"key": "2024-01-01"}, + "UNSUPPORTED_DEFINITION", + ), + ( + "non_date_property", + "2024-01-01", + "is_date_after", + {"key": 5}, + "MISSING_CONTEXT", + ), + ( + "invalid_flag_number", + "abc", + "gt", + {"key": 5}, + "UNSUPPORTED_DEFINITION", + ), + ( + "invalid_flag_number_and_missing_property", + "abc", + "gt", + {}, + "UNSUPPORTED_DEFINITION", + ), + ( + "non_json_property", + "x", + "exact", + {"key": object()}, + "MISSING_CONTEXT", + ), + ( + "non_finite_property", + "x", + "icontains", + {"key": float("nan")}, + "MISSING_CONTEXT", + ), + ( + "non_finite_flag_value", + float("nan"), + "exact", + {"key": "x"}, + "UNSUPPORTED_DEFINITION", + ), + ( + "non_finite_flag_value_and_property", + [float("inf")], + "exact", + {"key": float("nan")}, + "UNSUPPORTED_DEFINITION", + ), + ( + "non_finite_flag_value_and_missing_property", + float("nan"), + "icontains", + {}, + "UNSUPPORTED_DEFINITION", + ), + ( + "deferred_regex", + r"\bfoo", + "regex", + {"key": "foo"}, + "UNSUPPORTED_DEFINITION", + ), + ( + "deferred_regex_and_missing_property", + r"\bfoo", + "regex", + {}, + "UNSUPPORTED_DEFINITION", + ), + ( + "regex_deferred_for_property_value", + r"\w+", + "regex", + {"key": "é"}, + "UNSUPPORTED_DEFINITION", + ), + ] + ) + def test_inconclusive_match_reports_reason( + self, _name, value, operator, properties, reason + ): + with self.assertRaises(InconclusiveMatchError) as ctx: + match_property(self.property("key", value, operator), properties) + + self.assertEqual(ctx.exception.reason, UnresolvedFlagReason[reason]) + def test_every_supported_operator_has_a_dispatch_branch(self): # PROPERTY_OPERATORS gates local evaluation, but match_property dispatches # on a hand-written if-chain. An operator listed in the tuple but missing a @@ -6403,6 +6554,399 @@ def test_unknown_operator(self): ) +PLAN_IS_PRO = {"key": "plan", "operator": "exact", "value": "pro", "type": "person"} +PLAN_IS_FREE = {"key": "plan", "operator": "exact", "value": "free", "type": "person"} +EMAIL_IS_SET = {"key": "email", "operator": "is_set", "type": "person"} +UNKNOWN_OPERATOR = {"key": "plan", "operator": "bogus", "value": "x", "type": "person"} +STATIC_COHORT = {"key": "id", "value": 42, "type": "cohort"} + + +def _depends_on(key): + return { + "key": key, + "type": "flag", + "value": True, + "operator": "flag_evaluates_to", + "dependency_chain": [key], + } + + +def _flag(key, *conditions, **extra): + return { + "key": key, + "active": True, + "filters": { + "groups": [ + {"properties": list(properties), "rollout_percentage": 100} + for properties in conditions + ] + }, + **extra, + } + + +class TestUnresolvedReasons(unittest.TestCase): + def evaluate(self, flag, properties, flags=(), cohorts=None): + return match_feature_flag_properties( + flag, + "user-123", + properties, + cohort_properties=cohorts or {}, + flags_by_key={f["key"]: f for f in (flag, *flags)}, + evaluation_cache={}, + bucketing_value="user-123", + ) + + def assert_unresolved( + self, error_type, reason, flag, properties, flags=(), cohorts=None + ): + with self.assertRaises(error_type) as ctx: + self.evaluate(flag, properties, flags, cohorts) + self.assertIs(type(ctx.exception), error_type) + self.assertEqual(ctx.exception.reason, reason) + + @parameterized.expand( + [ + ( + "experience_continuity_first", + [None, UnresolvedFlagReason.EXPERIENCE_CONTINUITY], + UnresolvedFlagReason.EXPERIENCE_CONTINUITY, + ), + ( + "unsupported_over_missing", + [ + UnresolvedFlagReason.MISSING_CONTEXT, + UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ], + UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ), + ( + "dependency_over_missing", + [ + UnresolvedFlagReason.MISSING_CONTEXT, + UnresolvedFlagReason.UNRESOLVED_DEPENDENCY, + ], + UnresolvedFlagReason.UNRESOLVED_DEPENDENCY, + ), + ( + "unsupported_kept_over_dependency", + [ + UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + UnresolvedFlagReason.UNRESOLVED_DEPENDENCY, + ], + UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ), + ( + "experience_continuity_kept", + [ + UnresolvedFlagReason.EXPERIENCE_CONTINUITY, + UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ], + UnresolvedFlagReason.EXPERIENCE_CONTINUITY, + ), + ] + ) + def test_pick_unresolved_reason_follows_spec_order(self, _name, reasons, expected): + current, new = reasons + self.assertEqual(_pick_unresolved_reason(current, new), expected) + + @parameterized.expand( + [ + ("missing_property_first", [EMAIL_IS_SET, PLAN_IS_FREE]), + ("missing_property_last", [PLAN_IS_FREE, EMAIL_IS_SET]), + ("static_cohort_first", [STATIC_COHORT, PLAN_IS_FREE]), + ("unknown_operator_first", [UNKNOWN_OPERATOR, PLAN_IS_FREE]), + ] + ) + def test_filter_that_does_not_match_rules_condition_out(self, _name, properties): + self.assertIs( + self.evaluate(_flag("checkout", properties), {"plan": "pro"}), False + ) + + with self.assertRaises((InconclusiveMatchError, RequiresServerEvaluation)): + self.evaluate(_flag("checkout", properties), {"plan": "free"}) + + def test_condition_reports_highest_precedence_reason(self): + self.assert_unresolved( + InconclusiveMatchError, + UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + _flag("checkout", [PLAN_IS_PRO, UNKNOWN_OPERATOR]), + {}, + ) + + def test_static_cohort_first_unchecked_filter_requires_server(self): + self.assert_unresolved( + RequiresServerEvaluation, + UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + _flag("checkout", [STATIC_COHORT, PLAN_IS_PRO]), + {}, + ) + + def test_static_cohort_after_unchecked_filter_lets_later_conditions_resolve(self): + flag = _flag("checkout", [PLAN_IS_PRO, STATIC_COHORT], [EMAIL_IS_SET]) + + self.assertIs(self.evaluate(flag, {"email": "a@b.c"}), True) + self.assert_unresolved( + InconclusiveMatchError, + UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + flag, + {}, + ) + + def test_later_static_cohort_condition_keeps_higher_precedence_reason(self): + self.assert_unresolved( + RequiresServerEvaluation, + UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + _flag("checkout", [PLAN_IS_PRO], [STATIC_COHORT]), + {}, + ) + + @parameterized.expand( + [ + ("dependency_only", [[_depends_on("base")]], "unresolved_dependency"), + ( + "unknown_operator_first", + [[UNKNOWN_OPERATOR], [_depends_on("base")]], + "unsupported_definition", + ), + ( + "dependency_first", + [[_depends_on("base")], [UNKNOWN_OPERATOR]], + "unsupported_definition", + ), + ( + "dependency_reused_after_unchecked_filter", + [[EMAIL_IS_SET, _depends_on("base")], [_depends_on("base")]], + "unresolved_dependency", + ), + ( + "unknown_operator_after_dependency_in_same_condition", + [[_depends_on("base"), UNKNOWN_OPERATOR]], + "unsupported_definition", + ), + ( + "cohort_with_dependency_first", + [[{"key": "id", "value": 1, "type": "cohort"}]], + "unsupported_definition", + ), + ( + "cohort_with_unknown_operator_first", + [[{"key": "id", "value": 2, "type": "cohort"}]], + "unsupported_definition", + ), + ] + ) + def test_dependency_requiring_server_requires_server( + self, _name, conditions, reason + ): + cohorts = { + "1": {"type": "AND", "values": [_depends_on("base"), UNKNOWN_OPERATOR]}, + "2": {"type": "AND", "values": [UNKNOWN_OPERATOR, _depends_on("base")]}, + } + + self.assert_unresolved( + RequiresServerEvaluation, + UnresolvedFlagReason(reason), + _flag("checkout", *conditions), + {"plan": "pro"}, + flags=[_flag("base", [STATIC_COHORT])], + cohorts=cohorts, + ) + + def test_unexpected_error_after_inconclusive_filter_lets_later_conditions_resolve( + self, + ): + malformed_filter = "not-a-filter" + + self.assertIs( + self.evaluate( + _flag("checkout", [PLAN_IS_PRO, malformed_filter], [EMAIL_IS_SET]), + {"email": "a@b.c"}, + ), + True, + ) + with self.assertRaises(AttributeError): + self.evaluate( + _flag("checkout", [malformed_filter, PLAN_IS_PRO], [EMAIL_IS_SET]), + {"email": "a@b.c"}, + ) + + def test_dependency_with_experience_continuity_is_unresolved_dependency(self): + base = _flag("base", [], ensure_experience_continuity=True) + + self.assert_unresolved( + InconclusiveMatchError, + UnresolvedFlagReason.UNRESOLVED_DEPENDENCY, + _flag("checkout", [_depends_on("base")]), + {}, + flags=[base], + ) + + def test_inactive_dependency_with_experience_continuity_is_false(self): + base = _flag("base", [], active=False, ensure_experience_continuity=True) + checkout = _flag("checkout", [{**_depends_on("base"), "value": False}]) + + self.assertIs(self.evaluate(checkout, {}, flags=[base]), True) + + def test_missing_dependency_outranks_inconclusive_one_earlier_in_chain(self): + base = _flag("base", [PLAN_IS_PRO]) + dependency = {**_depends_on("missing"), "dependency_chain": ["base", "missing"]} + + self.assert_unresolved( + InconclusiveMatchError, + UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + _flag("checkout", [dependency]), + {}, + flags=[base], + ) + + @parameterized.expand( + [ + ("circular", []), + ("not_a_list", None), + ] + ) + def test_malformed_dependency_chain_is_unsupported_definition( + self, _name, dependency_chain + ): + dependency = {**_depends_on("base"), "dependency_chain": dependency_chain} + + self.assert_unresolved( + InconclusiveMatchError, + UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + _flag("checkout", [dependency]), + {}, + flags=[_flag("base", [])], + ) + + def test_missing_group_properties_is_missing_context(self): + flag = _flag("checkout", []) + flag["filters"]["groups"][0]["aggregation_group_type_index"] = 0 + + with self.assertRaises(InconclusiveMatchError) as ctx: + match_feature_flag_properties( + flag, + "user-123", + {}, + flags_by_key={"checkout": flag}, + evaluation_cache={}, + bucketing_value="user-123", + group_type_mapping={"0": "company"}, + groups={"company": "acme"}, + ) + self.assertEqual(ctx.exception.reason, UnresolvedFlagReason.MISSING_CONTEXT) + + def test_unsupported_cohort_operator_outranks_member_missing_context(self): + with self.assertRaises(InconclusiveMatchError) as ctx: + match_cohort( + {"key": "id", "value": 1, "type": "cohort", "operator": "bogus"}, + {}, + {"1": {"type": "AND", "values": [PLAN_IS_PRO]}}, + ) + self.assertEqual( + ctx.exception.reason, UnresolvedFlagReason.UNSUPPORTED_DEFINITION + ) + + def test_cohort_group_keeps_highest_precedence_reason(self): + with self.assertRaises(InconclusiveMatchError) as ctx: + match_property_group( + {"type": "AND", "values": [PLAN_IS_PRO, UNKNOWN_OPERATOR]}, {}, {} + ) + self.assertEqual( + ctx.exception.reason, UnresolvedFlagReason.UNSUPPORTED_DEFINITION + ) + + @parameterized.expand( + [ + ("resolvable", _flag("checkout", [PLAN_IS_PRO]), None), + ("inactive", _flag("checkout", [STATIC_COHORT], active=False), None), + ( + "experience_continuity", + _flag("checkout", [STATIC_COHORT], ensure_experience_continuity=True), + UnresolvedFlagReason.EXPERIENCE_CONTINUITY, + ), + ( + "static_cohort", + _flag("checkout", [PLAN_IS_PRO], [STATIC_COHORT]), + UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ), + ( + "unknown_operator", + _flag("checkout", [UNKNOWN_OPERATOR]), + UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ), + ( + "malformed_value", + _flag( + "checkout", + [{"key": "n", "operator": "gt", "value": "abc", "type": "person"}], + ), + UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ), + ( + "missing_dependency", + _flag("checkout", [_depends_on("missing")]), + UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ), + ( + "circular_dependency", + _flag( + "checkout", [{**_depends_on("checkout"), "dependency_chain": []}] + ), + UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ), + ( + "malformed_dependency_shape", + _flag("checkout", [{**_depends_on("missing"), "operator": "exact"}]), + None, + ), + ( + "unknown_group_type", + { + **_flag("checkout", []), + "filters": {"aggregation_group_type_index": 9, "groups": []}, + }, + UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ), + ( + "static_cohort_nested_in_cohort", + _flag("checkout", [{"key": "id", "value": 1, "type": "cohort"}]), + UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ), + ( + "cohort_cycle", + _flag("checkout", [{"key": "id", "value": 2, "type": "cohort"}]), + None, + ), + ( + "unknown_cohort_operator", + _flag( + "checkout", + [{"key": "id", "value": 2, "type": "cohort", "operator": "bogus"}], + ), + UnresolvedFlagReason.UNSUPPORTED_DEFINITION, + ), + ] + ) + def test_definition_unresolved_reason(self, _name, flag, expected): + cohorts = { + "1": {"type": "OR", "values": [STATIC_COHORT]}, + "2": { + "type": "OR", + "values": [{"key": "id", "value": 3, "type": "cohort"}], + }, + "3": { + "type": "OR", + "values": [{"key": "id", "value": 2, "type": "cohort"}], + }, + } + + self.assertEqual( + _get_definition_unresolved_reason(flag, {flag["key"]: flag}, cohorts, {}), + expected, + ) + + class TestDateParsing(unittest.TestCase): @parameterized.expand( [ diff --git a/posthog/types.py b/posthog/types.py index 308339319..994b0786c 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -99,6 +99,32 @@ def matches(self, other: "FeatureFlagEvaluationRuntime") -> bool: return self is other or FeatureFlagEvaluationRuntime.ALL in (self, other) +class UnresolvedFlagReason(str, Enum): + """Why local evaluation left a flag without a value. + + Reported by :attr:`FeatureFlagEvaluations.unresolved_flags`. Inheriting from + ``str`` keeps the members directly comparable to their string values. When more + than one cause applies, the first reason in the order below is reported. + + Values: + EXPERIENCE_CONTINUITY: The flag has experience continuity enabled, which + local evaluation never resolves. Change the flag to fix it. + UNSUPPORTED_DEFINITION: The flag uses something the local evaluator does not + support, such as a static cohort, an unknown operator, a malformed value, + a dependency with no loaded definition or a circular dependency. Change + the flag or upgrade the SDK to fix it. + UNRESOLVED_DEPENDENCY: A flag this flag depends on has a loaded definition + but was unresolved. + MISSING_CONTEXT: The call did not pass a property, group property or device + id the flag needs, or passed it in an unusable form. Pass it to fix it. + """ + + EXPERIENCE_CONTINUITY = "experience_continuity" + UNSUPPORTED_DEFINITION = "unsupported_definition" + UNRESOLVED_DEPENDENCY = "unresolved_dependency" + MISSING_CONTEXT = "missing_context" + + @dataclass(frozen=True) class FlagReason: """Reason metadata returned by the feature flag API. @@ -422,6 +448,8 @@ class FeatureFlagError: Error values: ERRORS_WHILE_COMPUTING: Server returned errorsWhileComputingFlags=true FLAG_MISSING: Requested flag not in API response + LOCAL_EVALUATION_INCONCLUSIVE: Requested flag has a loaded definition but + local evaluation was inconclusive and no remote value filled the gap QUOTA_LIMITED: Rate/quota limit exceeded TIMEOUT: Request timed out CONNECTION_ERROR: Network connectivity issue @@ -433,6 +461,7 @@ class FeatureFlagError: ERRORS_WHILE_COMPUTING = "errors_while_computing_flags" FLAG_MISSING = "flag_missing" + LOCAL_EVALUATION_INCONCLUSIVE = "local_evaluation_inconclusive" QUOTA_LIMITED = "quota_limited" TIMEOUT = "timeout" CONNECTION_ERROR = "connection_error" diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 25cbaa418..e0a651e42 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -30,6 +30,7 @@ alias posthog.OptionalSetArgs -> posthog.args.OptionalSetArgs alias posthog.RequiresServerEvaluation -> posthog.feature_flags.RequiresServerEvaluation alias posthog.SocketOptions -> posthog.request.SocketOptions alias posthog.Span -> posthog.tracing.span.Span +alias posthog.UnresolvedFlagReason -> posthog.types.UnresolvedFlagReason alias posthog.VERSION -> posthog.version.VERSION alias posthog.ai.PromptResult -> posthog.ai.prompts.PromptResult alias posthog.ai.PromptSource -> posthog.ai.prompts.PromptSource @@ -274,6 +275,7 @@ alias posthog.client.RequiresServerEvaluation -> posthog.feature_flags.RequiresS alias posthog.client.SendFeatureFlagsOptions -> posthog.types.SendFeatureFlagsOptions alias posthog.client.SizeLimitedDict -> posthog.utils.SizeLimitedDict alias posthog.client.Span -> posthog.tracing.span.Span +alias posthog.client.UnresolvedFlagReason -> posthog.types.UnresolvedFlagReason alias posthog.client.VERSION -> posthog.version.VERSION alias posthog.client.batch_post -> posthog.request.batch_post alias posthog.client.clean -> posthog.utils.clean @@ -320,7 +322,9 @@ 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.FlagValue -> posthog.types.FlagValue +alias posthog.feature_flag_evaluations.UnresolvedFlagReason -> posthog.types.UnresolvedFlagReason alias posthog.feature_flags.FlagValue -> posthog.types.FlagValue +alias posthog.feature_flags.UnresolvedFlagReason -> posthog.types.UnresolvedFlagReason alias posthog.feature_flags.convert_to_datetime_aware -> posthog.utils.convert_to_datetime_aware alias posthog.feature_flags.is_valid_regex -> posthog.utils.is_valid_regex alias posthog.feature_flags.utils -> posthog.utils @@ -904,14 +908,17 @@ attribute posthog.exception_utils.VariableSizeLimiter.current_size = 0 attribute posthog.exception_utils.VariableSizeLimiter.max_size = max_size attribute posthog.exception_utils.epoch = datetime(1970, 1, 1) attribute posthog.feature_flag_evaluations.FeatureFlagEvaluations.keys: List[str] +attribute posthog.feature_flag_evaluations.FeatureFlagEvaluations.unresolved_flags: Mapping[str, UnresolvedFlagReason] attribute posthog.feature_flags.ConditionMatch.MATCH = 'match' attribute posthog.feature_flags.ConditionMatch.NO_MATCH = 'no_match' attribute posthog.feature_flags.ConditionMatch.OUT_OF_ROLLOUT_BOUND = 'out_of_rollout_bound' attribute posthog.feature_flags.DATE_OPERATORS = ('is_date_before', 'is_date_after') attribute posthog.feature_flags.EQUALITY_OPERATORS = ('exact', 'is_not', 'is_set', 'is_not_set') +attribute posthog.feature_flags.InconclusiveMatchError.reason: UnresolvedFlagReason = UnresolvedFlagReason.UNSUPPORTED_DEFINITION attribute posthog.feature_flags.NONE_VALUES_ALLOWED_OPERATORS = ['is_not'] attribute posthog.feature_flags.NUMERIC_OPERATORS = ('gt', 'gte', 'lt', 'lte') attribute posthog.feature_flags.PROPERTY_OPERATORS = EQUALITY_OPERATORS + STRING_OPERATORS + NUMERIC_OPERATORS + DATE_OPERATORS + SEMVER_OPERATORS +attribute posthog.feature_flags.RequiresServerEvaluation.reason: UnresolvedFlagReason = UnresolvedFlagReason.UNSUPPORTED_DEFINITION attribute posthog.feature_flags.SEMVER_COMPARISON_OPERATORS = ('semver_eq', 'semver_neq', 'semver_gt', 'semver_gte', 'semver_lt', 'semver_lte') attribute posthog.feature_flags.SEMVER_OPERATORS = SEMVER_COMPARISON_OPERATORS + SEMVER_RANGE_OPERATORS attribute posthog.feature_flags.SEMVER_RANGE_OPERATORS = ('semver_tilde', 'semver_caret', 'semver_wildcard') @@ -1125,6 +1132,7 @@ attribute posthog.types.FeatureFlag.variant: Optional[str] attribute posthog.types.FeatureFlagError.CONNECTION_ERROR = 'connection_error' attribute posthog.types.FeatureFlagError.ERRORS_WHILE_COMPUTING = 'errors_while_computing_flags' attribute posthog.types.FeatureFlagError.FLAG_MISSING = 'flag_missing' +attribute posthog.types.FeatureFlagError.LOCAL_EVALUATION_INCONCLUSIVE = 'local_evaluation_inconclusive' attribute posthog.types.FeatureFlagError.QUOTA_LIMITED = 'quota_limited' attribute posthog.types.FeatureFlagError.TIMEOUT = 'timeout' attribute posthog.types.FeatureFlagError.UNKNOWN_ERROR = 'unknown_error' @@ -1159,6 +1167,10 @@ attribute posthog.types.SendFeatureFlagsOptions.group_properties: Optional[dict[ attribute posthog.types.SendFeatureFlagsOptions.only_evaluate_locally: Optional[bool] attribute posthog.types.SendFeatureFlagsOptions.person_properties: Optional[dict[str, Any]] attribute posthog.types.SendFeatureFlagsOptions.should_send: bool +attribute posthog.types.UnresolvedFlagReason.EXPERIENCE_CONTINUITY = 'experience_continuity' +attribute posthog.types.UnresolvedFlagReason.MISSING_CONTEXT = 'missing_context' +attribute posthog.types.UnresolvedFlagReason.UNRESOLVED_DEPENDENCY = 'unresolved_dependency' +attribute posthog.types.UnresolvedFlagReason.UNSUPPORTED_DEFINITION = 'unsupported_definition' attribute posthog.utils.CACHE_KEY_PREFIX = 'posthog:flags:' attribute posthog.utils.CACHE_MAX_SIZE = 10000 attribute posthog.utils.CACHE_STALE_TTL = 3600 @@ -1286,10 +1298,10 @@ class posthog.contexts.ContextScope(parent=None, fresh: bool = False, capture_ex class posthog.exception_capture.ExceptionCapture(client: Client, rate_limiting_enabled=False, bucket_size=DEFAULT_BUCKET_SIZE, refill_rate=DEFAULT_REFILL_RATE, refill_interval_seconds=DEFAULT_REFILL_INTERVAL_SECONDS) class posthog.exception_utils.AnnotatedValue(value, metadata) class posthog.exception_utils.VariableSizeLimiter(max_size=DEFAULT_TOTAL_VARIABLES_SIZE_LIMIT) -class posthog.feature_flag_evaluations.FeatureFlagEvaluations(host: _FeatureFlagEvaluationsHost, distinct_id: str, flags: Dict[str, _EvaluatedFlagRecord], groups: Optional[Mapping[str, Union[str, int]]] = None, disable_geoip: Optional[bool] = None, request_id: Optional[str] = None, evaluated_at: Optional[int] = None, errors_while_computing: bool = False, quota_limited: bool = False, minimal_flag_called_events: bool = False, accessed: Optional[Set[str]] = None) +class posthog.feature_flag_evaluations.FeatureFlagEvaluations(host: _FeatureFlagEvaluationsHost, distinct_id: str, flags: Dict[str, _EvaluatedFlagRecord], groups: Optional[Mapping[str, Union[str, int]]] = None, disable_geoip: Optional[bool] = None, request_id: Optional[str] = None, evaluated_at: Optional[int] = None, errors_while_computing: bool = False, quota_limited: bool = False, minimal_flag_called_events: bool = False, accessed: Optional[Set[str]] = None, unresolved_flags: Optional[Mapping[str, UnresolvedFlagReason]] = None) class posthog.feature_flags.ConditionMatch -class posthog.feature_flags.InconclusiveMatchError -class posthog.feature_flags.RequiresServerEvaluation +class posthog.feature_flags.InconclusiveMatchError(*args, reason: Optional[UnresolvedFlagReason] = None) +class posthog.feature_flags.RequiresServerEvaluation(*args, reason: Optional[UnresolvedFlagReason] = None) class posthog.flag_definition_cache.FlagDefinitionCacheData class posthog.flag_definition_cache.FlagDefinitionCacheProvider class posthog.integrations.asgi.PosthogASGIMiddleware(app: _ASGIApp, client: Optional[Client] = None, capture_exceptions: Optional[bool] = None, request_filter: Optional[_RequestFilter] = None, extra_properties: Optional[_ExtraProperties] = None, trust_tracing_headers: Optional[bool] = None, *, capture_tracing_headers: Optional[bool] = None) @@ -1330,6 +1342,7 @@ class posthog.types.FlagsAndPayloads class posthog.types.FlagsResponse class posthog.types.LegacyFlagMetadata(payload: Any) class posthog.types.SendFeatureFlagsOptions +class posthog.types.UnresolvedFlagReason class posthog.utils.FlagCache(max_size=CACHE_MAX_SIZE, default_ttl=CACHE_TTL) class posthog.utils.FlagCacheEntry(flag_result, flag_definition_version, timestamp=None) class posthog.utils.RedisFlagCache(redis_client, default_ttl=CACHE_TTL, stale_ttl=CACHE_STALE_TTL, key_prefix=CACHE_KEY_PREFIX)