diff --git a/.sampo/changesets/evaluate-flags-api.md b/.sampo/changesets/evaluate-flags-api.md new file mode 100644 index 00000000..1e37a39c --- /dev/null +++ b/.sampo/changesets/evaluate-flags-api.md @@ -0,0 +1,16 @@ +--- +pypi/posthog: minor +--- + +Add `evaluate_flags()` and a new `flags` option on `capture()` so a single `/flags` call can power both flag branching and event enrichment per request: + +```python +flags = posthog.evaluate_flags(distinct_id, person_properties={"plan": "enterprise"}) +if flags.is_enabled("new-dashboard"): + render_new_dashboard() +posthog.capture("page_viewed", distinct_id=distinct_id, flags=flags) +``` + +The returned `FeatureFlagEvaluations` snapshot exposes `is_enabled()`, `get_flag()`, `get_flag_payload()` for branching and `only_accessed()` / `only([keys])` filter helpers. Pass `flag_keys=[...]` to `evaluate_flags()` to scope the underlying `/flags` request itself. + +Deprecates `feature_enabled()`, `get_feature_flag()`, `get_feature_flag_payload()`, and `capture(send_feature_flags=...)`. They continue to work but now emit a `DeprecationWarning` pointing at `evaluate_flags()`. Removal is planned for the next major version. diff --git a/posthog/__init__.py b/posthog/__init__.py index b594c01a..e170954f 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -39,6 +39,9 @@ DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS, DEFAULT_CODE_VARIABLES_MASK_PATTERNS, ) +from posthog.feature_flag_evaluations import ( + FeatureFlagEvaluations as FeatureFlagEvaluations, +) from posthog.feature_flags import ( InconclusiveMatchError as InconclusiveMatchError, ) @@ -770,6 +773,61 @@ def get_all_flags_and_payloads( ) +def evaluate_flags( + distinct_id=None, # type: Optional[str] + groups=None, # type: Optional[Dict[str, str]] + person_properties=None, # type: Optional[Dict[str, Any]] + group_properties=None, # type: Optional[Dict[str, Dict[str, Any]]] + only_evaluate_locally=False, # type: bool + disable_geoip=None, # type: Optional[bool] + flag_keys=None, # type: Optional[list] +) -> FeatureFlagEvaluations: + """Evaluate all feature flags for a user in a single call and return a + :class:`FeatureFlagEvaluations` snapshot. Branch on ``.is_enabled()`` / + ``.get_flag()`` and pass the same snapshot to ``capture()`` via the + ``flags`` option so events carry the exact flag values the code branched on. + + Prefer this over repeated ``get_feature_flag()`` calls and over + ``capture(send_feature_flags=True)`` — it consolidates flag evaluation into + a single ``/flags`` request per incoming request. + + Args: + distinct_id: The user's distinct ID. If ``None``, falls back to the context + distinct_id. If still unresolvable, returns an empty snapshot. + groups: Mapping of group type to group key. + 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. + disable_geoip: Whether to disable GeoIP lookup. + flag_keys: Optional list of flag keys. When provided, only these flags are + evaluated — the underlying ``/flags`` request asks the server for just + this subset, which makes the response smaller and the request cheaper. + Use this when you only need a handful of flags out of many. + + Examples: + ```python + from posthog import evaluate_flags, capture + flags = evaluate_flags("user_123", person_properties={"plan": "enterprise"}) + if flags.is_enabled("new-dashboard"): + render_new_dashboard() + capture("page_viewed", distinct_id="user_123", flags=flags) + ``` + + Category: + Feature flags + """ + return _proxy( + "evaluate_flags", + distinct_id=distinct_id, + groups=groups, + person_properties=person_properties, + group_properties=group_properties, + only_evaluate_locally=only_evaluate_locally, + disable_geoip=disable_geoip, + flag_keys=flag_keys, + ) + + def feature_flag_definitions(): """ Returns loaded feature flags. diff --git a/posthog/args.py b/posthog/args.py index cac36424..7f69214b 100644 --- a/posthog/args.py +++ b/posthog/args.py @@ -1,4 +1,4 @@ -from typing import TypedDict, Optional, Any, Dict, Union, Tuple, Type +from typing import TYPE_CHECKING, TypedDict, Optional, Any, Dict, Union, Tuple, Type from types import TracebackType from typing_extensions import NotRequired # For Python < 3.11 compatibility from datetime import datetime @@ -7,6 +7,9 @@ from posthog.types import SendFeatureFlagsOptions +if TYPE_CHECKING: + from posthog.feature_flag_evaluations import FeatureFlagEvaluations + ID_TYPES = Union[numbers.Number, str, UUID, int] @@ -23,9 +26,14 @@ class OptionalCaptureArgs(TypedDict): UUID is returned, so you can correlate it with actions in your app (like showing users an error ID if you capture an exception). groups: Group identifiers to associate with this event (format: {group_type: group_key}) - send_feature_flags: Whether to include currently active feature flags in the event properties. - Can be a boolean (True/False) or a SendFeatureFlagsOptions object for advanced configuration. - Defaults to False. + flags: A ``FeatureFlagEvaluations`` snapshot from ``evaluate_flags()``. The exact flag + values from the snapshot are attached to the event with no additional network call — + prefer this over ``send_feature_flags``. + send_feature_flags: Deprecated — prefer ``flags`` with a ``FeatureFlagEvaluations`` + snapshot. Whether to include currently active feature flags in the event properties. + Can be a boolean or a SendFeatureFlagsOptions object. Defaults to False. Fires a + hidden ``/flags`` request on capture and may return different values than the ones + the code branched on. disable_geoip: Whether to disable GeoIP lookup for this event. Defaults to False. """ @@ -34,6 +42,7 @@ class OptionalCaptureArgs(TypedDict): timestamp: NotRequired[Optional[Union[datetime, str]]] uuid: NotRequired[Optional[str]] groups: NotRequired[Optional[Dict[str, str]]] + flags: NotRequired[Optional["FeatureFlagEvaluations"]] send_feature_flags: NotRequired[ Optional[Union[bool, SendFeatureFlagsOptions]] ] # Updated to support both boolean and options object diff --git a/posthog/client.py b/posthog/client.py index a4ffa1ed..f9570540 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1,10 +1,11 @@ import atexit +import json import logging import os import sys import warnings from datetime import datetime, timedelta, timezone -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, List, Optional, Union from uuid import uuid4 from typing_extensions import Unpack @@ -32,6 +33,11 @@ mark_exception_as_captured, try_attach_code_variables_to_frames, ) +from posthog.feature_flag_evaluations import ( + FeatureFlagEvaluations, + _EvaluatedFlagRecord, + _FeatureFlagEvaluationsHost, +) from posthog.feature_flags import ( InconclusiveMatchError, RequiresServerEvaluation, @@ -630,6 +636,7 @@ def capture( timestamp = kwargs.get("timestamp", None) uuid = kwargs.get("uuid", None) groups = kwargs.get("groups", None) + flags_snapshot = kwargs.get("flags", None) send_feature_flags = kwargs.get("send_feature_flags", False) disable_geoip = kwargs.get("disable_geoip", None) @@ -655,70 +662,91 @@ def capture( properties["$groups"] = groups extra_properties: dict[str, Any] = {} - feature_variants: Optional[dict[str, Union[bool, str]]] = {} - - # Parse and normalize send_feature_flags parameter - flag_options = self._parse_send_feature_flags(send_feature_flags) - if flag_options["should_send"]: - try: - if flag_options["only_evaluate_locally"] is True: - # Local evaluation explicitly requested - feature_variants = self.get_all_flags( - distinct_id, - groups=(groups or {}), - person_properties=flag_options["person_properties"], - group_properties=flag_options["group_properties"], - disable_geoip=disable_geoip, - only_evaluate_locally=True, - flag_keys_to_evaluate=flag_options["flag_keys_filter"], - ) - elif flag_options["only_evaluate_locally"] is False: - # Remote evaluation explicitly requested - feature_variants = self.get_feature_variants( - distinct_id, - groups, - person_properties=flag_options["person_properties"], - group_properties=flag_options["group_properties"], - disable_geoip=disable_geoip, - flag_keys_to_evaluate=flag_options["flag_keys_filter"], - ) - elif self.feature_flags: - # Local flags available, prefer local evaluation - feature_variants = self.get_all_flags( - distinct_id, - groups=(groups or {}), - person_properties=flag_options["person_properties"], - group_properties=flag_options["group_properties"], - disable_geoip=disable_geoip, - only_evaluate_locally=True, - flag_keys_to_evaluate=flag_options["flag_keys_filter"], - ) - else: - # Fall back to remote evaluation - feature_variants = self.get_feature_variants( - distinct_id, - groups, - person_properties=flag_options["person_properties"], - group_properties=flag_options["group_properties"], - disable_geoip=disable_geoip, - flag_keys_to_evaluate=flag_options["flag_keys_filter"], - ) - except Exception as e: - self.log.exception( - f"[FEATURE FLAGS] Unable to get feature variants: {e}" + # Precedence: an explicit ``flags`` snapshot always wins, regardless of + # ``send_feature_flags``. The snapshot guarantees the event carries the same + # values the developer branched on with no additional network call. The + # ``send_feature_flags`` path only runs when no snapshot is provided. + if flags_snapshot is not None: + if send_feature_flags: + self.log.warning( + "[FEATURE FLAGS] Both `flags` and `send_feature_flags` were passed to " + "capture(); using `flags` and ignoring `send_feature_flags`." + ) + extra_properties = flags_snapshot._get_event_properties() + else: + feature_variants: Optional[dict[str, Union[bool, str]]] = {} + + # Parse and normalize send_feature_flags parameter + flag_options = self._parse_send_feature_flags(send_feature_flags) + + if flag_options["should_send"]: + warnings.warn( + "`send_feature_flags` is deprecated and will be removed in a future major " + "version. Pass a `flags` snapshot from `posthog.evaluate_flags(...)` instead " + "— it avoids a second `/flags` request per capture and guarantees the event " + "carries the exact flag values your code branched on.", + DeprecationWarning, + stacklevel=2, ) + try: + if flag_options["only_evaluate_locally"] is True: + # Local evaluation explicitly requested + feature_variants = self.get_all_flags( + distinct_id, + groups=(groups or {}), + person_properties=flag_options["person_properties"], + group_properties=flag_options["group_properties"], + disable_geoip=disable_geoip, + only_evaluate_locally=True, + flag_keys_to_evaluate=flag_options["flag_keys_filter"], + ) + elif flag_options["only_evaluate_locally"] is False: + # Remote evaluation explicitly requested + feature_variants = self.get_feature_variants( + distinct_id, + groups, + person_properties=flag_options["person_properties"], + group_properties=flag_options["group_properties"], + disable_geoip=disable_geoip, + flag_keys_to_evaluate=flag_options["flag_keys_filter"], + ) + elif self.feature_flags: + # Local flags available, prefer local evaluation + feature_variants = self.get_all_flags( + distinct_id, + groups=(groups or {}), + person_properties=flag_options["person_properties"], + group_properties=flag_options["group_properties"], + disable_geoip=disable_geoip, + only_evaluate_locally=True, + flag_keys_to_evaluate=flag_options["flag_keys_filter"], + ) + else: + # Fall back to remote evaluation + feature_variants = self.get_feature_variants( + distinct_id, + groups, + person_properties=flag_options["person_properties"], + group_properties=flag_options["group_properties"], + disable_geoip=disable_geoip, + flag_keys_to_evaluate=flag_options["flag_keys_filter"], + ) + except Exception as e: + self.log.exception( + f"[FEATURE FLAGS] Unable to get feature variants: {e}" + ) - for feature, variant in (feature_variants or {}).items(): - extra_properties[f"$feature/{feature}"] = variant + for feature, variant in (feature_variants or {}).items(): + extra_properties[f"$feature/{feature}"] = variant - active_feature_flags = [ - key - for (key, value) in (feature_variants or {}).items() - if value is not False - ] - if active_feature_flags: - extra_properties["$active_feature_flags"] = active_feature_flags + active_feature_flags = [ + key + for (key, value) in (feature_variants or {}).items() + if value is not False + ] + if active_feature_flags: + extra_properties["$active_feature_flags"] = active_feature_flags if extra_properties: properties = {**extra_properties, **properties} @@ -979,7 +1007,9 @@ def capture_exception( exception: The exception to capture. distinct_id: The distinct ID of the user. properties: A dictionary of additional properties. - send_feature_flags: Whether to send feature flags with the exception. + flags: A ``FeatureFlagEvaluations`` snapshot from ``evaluate_flags()``. + Attaches those exact flag values to the captured `$exception` event. + send_feature_flags: Deprecated. Pass ``flags`` from ``evaluate_flags()`` instead. disable_geoip: Whether to disable GeoIP for this event. Examples: @@ -996,6 +1026,7 @@ def capture_exception( """ distinct_id = kwargs.get("distinct_id", None) properties = kwargs.get("properties", None) + flags_snapshot = kwargs.get("flags", None) send_feature_flags = kwargs.get("send_feature_flags", False) disable_geoip = kwargs.get("disable_geoip", None) # this function shouldn't ever throw an error, so it logs exceptions instead of raising them. @@ -1078,6 +1109,7 @@ def capture_exception( timestamp=timestamp, uuid=uuid, groups=groups, + flags=flags_snapshot, send_feature_flags=send_feature_flags, disable_geoip=disable_geoip, ) @@ -1554,7 +1586,17 @@ def feature_enabled( Category: Feature flags """ - response = self.get_feature_flag( + warnings.warn( + "`feature_enabled` is deprecated and will be removed in a future major version. " + "Use `posthog.evaluate_flags(distinct_id, ...)` and call `flags.is_enabled(key)` " + "instead — this consolidates flag evaluation into a single `/flags` request per " + "incoming request.", + DeprecationWarning, + stacklevel=2, + ) + # Bypass the public `get_feature_flag` so the user only sees a single deprecation + # warning per call, not three (feature_enabled → get_feature_flag → get_feature_flag_result). + flag_result = self._get_feature_flag_result( key, distinct_id, groups=groups, @@ -1565,6 +1607,7 @@ def feature_enabled( disable_geoip=disable_geoip, device_id=device_id, ) + response = flag_result.get_value() if flag_result else None if response is None: return None @@ -1814,7 +1857,17 @@ def get_feature_flag( Category: Feature flags """ - feature_flag_result = self.get_feature_flag_result( + warnings.warn( + "`get_feature_flag` is deprecated and will be removed in a future major version. " + "Use `posthog.evaluate_flags(distinct_id, ...)` and call `flags.get_flag(key)` " + "instead — this consolidates flag evaluation into a single `/flags` request per " + "incoming request.", + DeprecationWarning, + stacklevel=2, + ) + # Bypass the public `get_feature_flag_result` so the user only sees one deprecation + # warning per call. + feature_flag_result = self._get_feature_flag_result( key, distinct_id, groups=groups, @@ -1909,6 +1962,14 @@ def get_feature_flag_payload( Category: Feature flags """ + warnings.warn( + "`get_feature_flag_payload` is deprecated and will be removed in a future major " + "version. Use `posthog.evaluate_flags(distinct_id, ...)` and call " + "`flags.get_flag_payload(key)` instead — this consolidates flag evaluation into " + "a single `/flags` request per incoming request.", + DeprecationWarning, + stacklevel=2, + ) if send_feature_flag_events: warnings.warn( "send_feature_flag_events is deprecated in get_feature_flag_payload() and will be removed " @@ -1975,6 +2036,56 @@ def _capture_feature_flag_called( flag_details: Optional[FeatureFlag], feature_flag_error: Optional[str] = None, ): + properties: dict[str, Any] = { + "$feature_flag": key, + "$feature_flag_response": response, + "locally_evaluated": flag_was_locally_evaluated, + f"$feature/{key}": response, + } + + if payload is not None: + # if payload is not a string, json serialize it to a string + properties["$feature_flag_payload"] = payload + + if request_id: + properties["$feature_flag_request_id"] = request_id + if evaluated_at: + properties["$feature_flag_evaluated_at"] = evaluated_at + if isinstance(flag_details, FeatureFlag): + if flag_details.reason and flag_details.reason.description: + properties["$feature_flag_reason"] = flag_details.reason.description + if isinstance(flag_details.metadata, FlagMetadata): + if flag_details.metadata.version: + properties["$feature_flag_version"] = flag_details.metadata.version + if flag_details.metadata.id: + properties["$feature_flag_id"] = flag_details.metadata.id + if feature_flag_error: + properties["$feature_flag_error"] = feature_flag_error + + self._capture_feature_flag_called_if_needed( + distinct_id=distinct_id, + key=key, + response=response, + properties=properties, + groups=groups, + disable_geoip=disable_geoip, + ) + + def _capture_feature_flag_called_if_needed( + self, + *, + distinct_id: ID_TYPES, + key: str, + response: Optional[FlagValue], + properties: dict[str, Any], + groups: Optional[Dict[str, str]] = None, + disable_geoip: Optional[bool] = None, + ) -> None: + """Fire a ``$feature_flag_called`` event if the (distinct_id, flag, response) + triple hasn't already been reported on this client. Shared by the single-flag + evaluation path and ``FeatureFlagEvaluations.is_enabled() / get_flag()`` so + both paths dedupe identically. + """ feature_flag_reported_key = ( f"{key}_{'::null::' if response is None else str(response)}" ) @@ -1984,43 +2095,17 @@ def _capture_feature_flag_called( reported_flags = set() self.distinct_ids_feature_flags_reported[distinct_id] = reported_flags - if feature_flag_reported_key not in reported_flags: - properties: dict[str, Any] = { - "$feature_flag": key, - "$feature_flag_response": response, - "locally_evaluated": flag_was_locally_evaluated, - f"$feature/{key}": response, - } - - if payload is not None: - # if payload is not a string, json serialize it to a string - properties["$feature_flag_payload"] = payload - - if request_id: - properties["$feature_flag_request_id"] = request_id - if evaluated_at: - properties["$feature_flag_evaluated_at"] = evaluated_at - if isinstance(flag_details, FeatureFlag): - if flag_details.reason and flag_details.reason.description: - properties["$feature_flag_reason"] = flag_details.reason.description - if isinstance(flag_details.metadata, FlagMetadata): - if flag_details.metadata.version: - properties["$feature_flag_version"] = ( - flag_details.metadata.version - ) - if flag_details.metadata.id: - properties["$feature_flag_id"] = flag_details.metadata.id - if feature_flag_error: - properties["$feature_flag_error"] = feature_flag_error + if feature_flag_reported_key in reported_flags: + return - self.capture( - "$feature_flag_called", - distinct_id=distinct_id, - properties=properties, - groups=groups, - disable_geoip=disable_geoip, - ) - reported_flags.add(feature_flag_reported_key) + self.capture( + "$feature_flag_called", + distinct_id=distinct_id, + properties=properties, + groups=groups or {}, + disable_geoip=disable_geoip, + ) + reported_flags.add(feature_flag_reported_key) def get_remote_config_payload(self, key: str): if self.disabled: @@ -2189,6 +2274,208 @@ def get_all_flags_and_payloads( return response + def evaluate_flags( + self, + distinct_id: Optional[ID_TYPES] = None, + *, + groups: Optional[Dict[str, str]] = None, + person_properties: Optional[Dict[str, Any]] = None, + group_properties: Optional[Dict[str, Dict[str, Any]]] = None, + only_evaluate_locally: bool = False, + disable_geoip: Optional[bool] = None, + flag_keys: Optional[List[str]] = None, + ) -> FeatureFlagEvaluations: + """Evaluate all feature flags for a user in a single call and return a + :class:`FeatureFlagEvaluations` snapshot. Branch on ``.is_enabled()`` / + ``.get_flag()`` and pass the same snapshot to :meth:`capture` via the + ``flags`` option so events carry the exact flag values the code branched on. + + Prefer this over repeated ``get_feature_flag()`` calls and over + ``capture(send_feature_flags=True)`` — it consolidates flag evaluation into + a single ``/flags`` request per incoming request. + + Local evaluation is transparent: when the poller resolves a flag, the + snapshot's ``$feature_flag_called`` events are tagged ``locally_evaluated=True`` + and reason ``"Evaluated locally"``. + + Args: + distinct_id: The user's distinct ID. If ``None``, falls back to the + context distinct_id. If still unresolvable, returns an empty snapshot. + groups: Mapping of group type to group key. + 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. + disable_geoip: Whether to disable GeoIP lookup. + flag_keys: Optional list of flag keys to scope the underlying ``/flags`` + request to a subset. + + Returns: + A :class:`FeatureFlagEvaluations` snapshot. + + Examples: + ```python + flags = posthog.evaluate_flags( + "user_123", + person_properties={"plan": "enterprise"}, + ) + if flags.is_enabled("new-dashboard"): + render_new_dashboard() + posthog.capture("page_viewed", distinct_id="user_123", flags=flags) + ``` + + Category: + Feature flags + """ + host = self._get_feature_flag_evaluations_host() + + if distinct_id is None: + distinct_id = get_context_distinct_id() + + # Resolve device_id from context (may be set via tracing headers) so the + # remote /flags request can use it for experience-continuity flag matching. + device_id = get_context_device_id() + + if not distinct_id or self.disabled: + # Empty snapshot. The class short-circuits on empty distinct_id so calling + # is_enabled()/get_flag() on it won't emit events. + return FeatureFlagEvaluations(host=host, distinct_id="", flags={}) + + person_properties, group_properties = ( + self._add_local_person_and_group_properties( + distinct_id, + groups or {}, + person_properties or {}, + group_properties or {}, + ) + ) + groups = groups or {} + + records: Dict[str, _EvaluatedFlagRecord] = {} + request_id: Optional[str] = None + evaluated_at: Optional[int] = None + errors_while_computing = False + quota_limited = False + locally_evaluated_keys: set[str] = set() + + # Try local evaluation first when the poller has loaded definitions. + local_result, fallback_to_server = self._get_all_flags_and_payloads_locally( + distinct_id, + groups=dict(groups), + person_properties=person_properties, + group_properties=group_properties, + flag_keys_to_evaluate=flag_keys, + ) + + feature_flags_by_key: Dict[str, Any] = self.feature_flags_by_key or {} + local_flags = local_result.get("featureFlags") or {} + local_payloads = local_result.get("featureFlagPayloads") or {} + for key, value in local_flags.items(): + flag_def = feature_flags_by_key.get(key) or {} + records[key] = _EvaluatedFlagRecord( + key=key, + enabled=value is not False, + variant=value if isinstance(value, str) else None, + payload=local_payloads.get(key), + id=flag_def.get("id"), + # The local-evaluation flag definition does not carry a version field; + # only the remote ``/flags`` response does via ``metadata.version``. + version=None, + reason="Evaluated locally", + locally_evaluated=True, + ) + locally_evaluated_keys.add(key) + + # Fall back to remote evaluation for any flags the poller couldn't resolve locally. + # Use ``get_flags_decision`` directly so the resulting records carry id/version/reason + # and fired ``$feature_flag_called`` events match what ``get_feature_flag()`` emits. + if fallback_to_server and not only_evaluate_locally: + try: + response = self.get_flags_decision( + distinct_id, + groups=groups, + person_properties=person_properties, + group_properties=group_properties, + disable_geoip=disable_geoip, + flag_keys_to_evaluate=flag_keys, + device_id=device_id, + ) + request_id = response.get("requestId") + raw_evaluated_at = response.get("evaluatedAt") + evaluated_at = ( + raw_evaluated_at if isinstance(raw_evaluated_at, int) else None + ) + errors_while_computing = bool( + response.get("errorsWhileComputingFlags", False) + ) + for key, detail in response.get("flags", {}).items(): + if key in locally_evaluated_keys: + continue + payload: Optional[Any] = None + raw_payload = ( + detail.metadata.payload + if isinstance(detail.metadata, FlagMetadata) + else getattr(detail.metadata, "payload", None) + ) + if isinstance(raw_payload, str) and raw_payload: + try: + payload = json.loads(raw_payload) + except (json.JSONDecodeError, TypeError): + payload = raw_payload + elif raw_payload is not None: + payload = raw_payload + records[key] = _EvaluatedFlagRecord( + key=key, + enabled=detail.enabled, + variant=detail.variant, + payload=payload, + id=( + detail.metadata.id + if isinstance(detail.metadata, FlagMetadata) + else None + ), + version=( + detail.metadata.version + if isinstance(detail.metadata, FlagMetadata) + else None + ), + reason=( + detail.reason.description + if detail.reason and detail.reason.description + else None + ), + locally_evaluated=False, + ) + except QuotaLimitError as e: + self.log.warning(f"[FEATURE FLAGS] Quota limit exceeded: {e}") + quota_limited = True + except Exception as e: + self.log.exception( + f"[FEATURE FLAGS] Unable to evaluate flags remotely: {e}" + ) + + return FeatureFlagEvaluations( + host=host, + distinct_id=str(distinct_id), + flags=records, + groups=groups, + disable_geoip=disable_geoip, + request_id=request_id, + evaluated_at=evaluated_at, + errors_while_computing=errors_while_computing, + quota_limited=quota_limited, + ) + + _feature_flag_evaluations_host_cache: Optional[_FeatureFlagEvaluationsHost] = None + + def _get_feature_flag_evaluations_host(self) -> _FeatureFlagEvaluationsHost: + if self._feature_flag_evaluations_host_cache is None: + self._feature_flag_evaluations_host_cache = _FeatureFlagEvaluationsHost( + capture_flag_called_event_if_needed=self._capture_feature_flag_called_if_needed, + log_warning=lambda message: self.log.warning(message), + ) + return self._feature_flag_evaluations_host_cache + def _get_all_flags_and_payloads_locally( self, distinct_id: ID_TYPES, diff --git a/posthog/feature_flag_evaluations.py b/posthog/feature_flag_evaluations.py new file mode 100644 index 00000000..88bcf916 --- /dev/null +++ b/posthog/feature_flag_evaluations.py @@ -0,0 +1,261 @@ +"""FeatureFlagEvaluations — a snapshot of feature flag values for a single distinct_id. + +Returned by Client.evaluate_flags(). Branch on .is_enabled() / .get_flag(), then pass +the same snapshot to capture() via the `flags` option so events carry the exact flag +values the code branched on, with no additional /flags request. +""" + +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Optional, Set + +from posthog.types import FlagValue + + +@dataclass(frozen=True) +class _EvaluatedFlagRecord: + """Internal per-flag record stored by a FeatureFlagEvaluations instance.""" + + key: str + enabled: bool + variant: Optional[str] + payload: Optional[Any] + id: Optional[int] + version: Optional[int] + reason: Optional[str] + locally_evaluated: bool + + +@dataclass +class _FeatureFlagEvaluationsHost: + """Callbacks the evaluations object uses to talk back to the client. + + Kept as a plain dataclass of callables so the class stays decoupled from the + full Client surface — this also makes it trivial to construct a fake host in tests. + """ + + capture_flag_called_event_if_needed: Callable[..., None] + log_warning: Callable[[str], None] + + +class FeatureFlagEvaluations: + """A point-in-time snapshot of feature flag evaluations for a single distinct_id. + + Returned by :meth:`Client.evaluate_flags` — branch on :meth:`is_enabled` / + :meth:`get_flag` and pass the same object to :meth:`Client.capture` via the + ``flags`` option so the captured event carries the exact flag values the code + branched on. + + Example:: + + flags = posthog.evaluate_flags(distinct_id, person_properties={"plan": "enterprise"}) + if flags.is_enabled("new-dashboard"): + render_new_dashboard() + posthog.capture("page_viewed", distinct_id=distinct_id, flags=flags) + + To narrow the set of flags that get attached to a captured event, use the in-memory + helpers :meth:`only` and :meth:`only_accessed`. To narrow the set of flags requested + from the server in the first place, pass ``flag_keys`` to :meth:`Client.evaluate_flags`. + """ + + def __init__( + self, + host: _FeatureFlagEvaluationsHost, + distinct_id: str, + flags: Dict[str, _EvaluatedFlagRecord], + groups: Optional[Dict[str, str]] = 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, + accessed: Optional[Set[str]] = None, + ) -> None: + """Internal — instances are created by the SDK via ``Client.evaluate_flags()``.""" + self._host = host + self._distinct_id = distinct_id + self._flags = flags + self._groups: Dict[str, str] = groups or {} + self._disable_geoip = disable_geoip + self._request_id = request_id + self._evaluated_at = evaluated_at + self._errors_while_computing = errors_while_computing + self._quota_limited = quota_limited + self._accessed: Set[str] = set(accessed) if accessed is not None else set() + + def is_enabled(self, key: str) -> bool: + """Return whether the flag is enabled. Fires ``$feature_flag_called`` on the + first access per (distinct_id, flag, value) tuple, deduped via the SDK's cache. + + Flags that were not returned from the underlying evaluation are treated as + disabled (returns ``False``). + """ + flag = self._flags.get(key) + self._record_access(key) + return bool(flag.enabled) if flag else False + + def get_flag(self, key: str) -> Optional[FlagValue]: + """Return the flag value. Fires ``$feature_flag_called`` on first access. + + Returns the variant string for multivariate flags, ``True`` for enabled flags + without a variant, ``False`` for disabled flags, and ``None`` for flags that + were not returned by the evaluation. + """ + flag = self._flags.get(key) + self._record_access(key) + if not flag: + return None + if not flag.enabled: + return False + return flag.variant if flag.variant is not None else True + + def get_flag_payload(self, key: str) -> Optional[Any]: + """Return the payload associated with a flag. + + Does not count as an access for :meth:`only_accessed` and does not fire any event. + """ + flag = self._flags.get(key) + return flag.payload if flag else None + + def only_accessed(self) -> "FeatureFlagEvaluations": + """Return a filtered copy containing only flags accessed via :meth:`is_enabled` + or :meth:`get_flag` before this call. + + Order-dependent: if nothing has been accessed yet, the returned snapshot is + empty. The method honors its name — pre-access if you want a populated result. + """ + filtered = {k: self._flags[k] for k in self._accessed if k in self._flags} + return self._clone_with(filtered) + + def only(self, keys: List[str]) -> "FeatureFlagEvaluations": + """Return a filtered copy containing only flags with the given keys. Keys that + are not present in the evaluation are dropped and logged as a warning. + """ + filtered: Dict[str, _EvaluatedFlagRecord] = {} + missing: List[str] = [] + for key in keys: + flag = self._flags.get(key) + if flag is not None: + filtered[key] = flag + else: + missing.append(key) + if missing: + self._host.log_warning( + "FeatureFlagEvaluations.only() was called with flag keys that are not in the " + f"evaluation set and will be dropped: {', '.join(missing)}" + ) + return self._clone_with(filtered) + + @property + def keys(self) -> List[str]: + """Return the flag keys that are part of this evaluation.""" + return list(self._flags.keys()) + + # --- Internal ------------------------------------------------------------- + + def _get_event_properties(self) -> Dict[str, Any]: + """Build the ``$feature/*`` and ``$active_feature_flags`` properties for an event. + + Internal — called by capture() when an event is captured with ``flags=...``. + """ + properties: Dict[str, Any] = {} + active_flags: List[str] = [] + for key, flag in self._flags.items(): + value: FlagValue = ( + False + if not flag.enabled + else (flag.variant if flag.variant is not None else True) + ) + properties[f"$feature/{key}"] = value + if flag.enabled: + active_flags.append(key) + if active_flags: + properties["$active_feature_flags"] = sorted(active_flags) + return properties + + @property + def _internal_distinct_id(self) -> str: + return self._distinct_id + + @property + def _internal_groups(self) -> Dict[str, str]: + return self._groups + + def _clone_with( + self, flags: Dict[str, _EvaluatedFlagRecord] + ) -> "FeatureFlagEvaluations": + return FeatureFlagEvaluations( + host=self._host, + distinct_id=self._distinct_id, + flags=flags, + groups=self._groups, + disable_geoip=self._disable_geoip, + request_id=self._request_id, + evaluated_at=self._evaluated_at, + errors_while_computing=self._errors_while_computing, + quota_limited=self._quota_limited, + # Copy the accessed set so the child tracks further access independently + # of the parent. Callers expect ``only_accessed()`` on the parent to reflect + # only what the parent saw, not what happened on filtered views. + accessed=set(self._accessed), + ) + + def _record_access(self, key: str) -> None: + self._accessed.add(key) + + # Empty snapshots (no resolvable distinct_id) are returned by ``evaluate_flags()`` + # as a safety fallback. Firing $feature_flag_called for them would emit events + # with an empty distinct_id, polluting analytics — short-circuit here. + if not self._distinct_id: + return + + flag = self._flags.get(key) + if flag is None: + response: Optional[FlagValue] = None + elif not flag.enabled: + response = False + else: + response = flag.variant if flag.variant is not None else True + + properties: Dict[str, Any] = { + "$feature_flag": key, + "$feature_flag_response": response, + "locally_evaluated": flag.locally_evaluated if flag else False, + f"$feature/{key}": response, + } + + if flag is not None: + if flag.payload is not None: + properties["$feature_flag_payload"] = flag.payload + if flag.id: + properties["$feature_flag_id"] = flag.id + if flag.version: + properties["$feature_flag_version"] = flag.version + if flag.reason: + properties["$feature_flag_reason"] = flag.reason + + if self._request_id: + properties["$feature_flag_request_id"] = self._request_id + if self._evaluated_at and not (flag and flag.locally_evaluated): + properties["$feature_flag_evaluated_at"] = self._evaluated_at + + # 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. + 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") + if errors: + properties["$feature_flag_error"] = ",".join(errors) + + self._host.capture_flag_called_event_if_needed( + distinct_id=self._distinct_id, + key=key, + response=response, + groups=self._groups, + disable_geoip=self._disable_geoip, + properties=properties, + ) diff --git a/posthog/test/test_evaluate_flags.py b/posthog/test/test_evaluate_flags.py new file mode 100644 index 00000000..4c360440 --- /dev/null +++ b/posthog/test/test_evaluate_flags.py @@ -0,0 +1,506 @@ +import unittest +import warnings +from unittest import mock + +from parameterized import parameterized + +from posthog.client import Client +from posthog.feature_flag_evaluations import FeatureFlagEvaluations +from posthog.test.test_utils import FAKE_TEST_API_KEY + + +def _flags_response_fixture(): + return { + "flags": { + "variant-flag": { + "key": "variant-flag", + "enabled": True, + "variant": "variant-value", + "reason": {"code": "variant", "description": "Matched condition set 3"}, + "metadata": {"id": 2, "version": 23, "payload": '{"key": "value"}'}, + }, + "boolean-flag": { + "key": "boolean-flag", + "enabled": True, + "variant": None, + "reason": {"code": "boolean", "description": "Matched condition set 1"}, + "metadata": {"id": 1, "version": 12}, + }, + "disabled-flag": { + "key": "disabled-flag", + "enabled": False, + "variant": None, + "reason": { + "code": "boolean", + "description": "Did not match any condition", + }, + "metadata": {"id": 3, "version": 2}, + }, + }, + "requestId": "request-id-1", + "evaluatedAt": 1640995200000, + } + + +class TestEvaluateFlagsRemote(unittest.TestCase): + def setUp(self): + self.client = Client(FAKE_TEST_API_KEY) + + def tearDown(self): + self.client.shutdown() + + @mock.patch("posthog.client.flags") + def test_returns_a_FeatureFlagEvaluations_instance(self, patch_flags): + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + self.assertIsInstance(flags, FeatureFlagEvaluations) + self.assertEqual(patch_flags.call_count, 1) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_does_not_fire_events_for_unaccessed_flags( + self, patch_capture, patch_flags + ): + patch_flags.return_value = _flags_response_fixture() + self.client.evaluate_flags("user-1") + feature_flag_called = [ + c + for c in patch_capture.call_args_list + if c[0] and c[0][0] == "$feature_flag_called" + ] + self.assertEqual(len(feature_flag_called), 0) + + @parameterized.expand( + [ + ("boolean_flag_is_enabled", "boolean-flag", True), + ("disabled_flag_is_disabled", "disabled-flag", False), + ("variant_flag_is_enabled", "variant-flag", True), + ] + ) + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_is_enabled(self, _name, key, expected, patch_capture, patch_flags): + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + + self.assertEqual(flags.is_enabled(key), expected) + + flag_called = [ + c + for c in patch_capture.call_args_list + if c[0] + and c[0][0] == "$feature_flag_called" + and c[1]["properties"]["$feature_flag"] == key + ] + self.assertEqual(len(flag_called), 1) + + @parameterized.expand( + [ + ( + "variant_flag_returns_variant_string", + "variant-flag", + "variant-value", + { + "$feature_flag_response": "variant-value", + "$feature_flag_id": 2, + "$feature_flag_version": 23, + "$feature_flag_reason": "Matched condition set 3", + "$feature_flag_request_id": "request-id-1", + "locally_evaluated": False, + }, + ), + ( + "boolean_flag_returns_true", + "boolean-flag", + True, + { + "$feature_flag_response": True, + "$feature_flag_id": 1, + "$feature_flag_version": 12, + "locally_evaluated": False, + }, + ), + ( + "disabled_flag_returns_false", + "disabled-flag", + False, + {"$feature_flag_response": False, "locally_evaluated": False}, + ), + ] + ) + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_get_flag_known_keys( + self, _name, key, expected, expected_props, patch_capture, patch_flags + ): + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + + self.assertEqual(flags.get_flag(key), expected) + + by_key = { + 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" + } + self.assertIn(key, by_key) + for prop, value in expected_props.items(): + self.assertEqual(by_key[key][prop], value) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_get_flag_missing_key_emits_flag_missing_error( + self, patch_capture, patch_flags + ): + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + + self.assertIsNone(flags.get_flag("missing-flag")) + + by_key = { + 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" + } + self.assertIsNone(by_key["missing-flag"]["$feature_flag_response"]) + self.assertEqual(by_key["missing-flag"]["$feature_flag_error"], "flag_missing") + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_errors_while_computing_flags_propagates_to_event( + self, patch_capture, patch_flags + ): + # Response-level errors are combined with per-flag errors so each + # $feature_flag_called event carries the granular error code(s). + response = _flags_response_fixture() + response["errorsWhileComputingFlags"] = True + patch_flags.return_value = response + + flags = self.client.evaluate_flags("user-1") + flags.is_enabled("boolean-flag") # known flag — only response-level error + flags.is_enabled("missing-flag") # missing — both errors combined + + by_key = { + 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" + } + self.assertEqual( + by_key["boolean-flag"]["$feature_flag_error"], + "errors_while_computing_flags", + ) + self.assertEqual( + by_key["missing-flag"]["$feature_flag_error"], + "errors_while_computing_flags,flag_missing", + ) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_dedupes_repeated_access(self, patch_capture, patch_flags): + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + + flags.is_enabled("boolean-flag") + flags.is_enabled("boolean-flag") + flags.get_flag("boolean-flag") + + boolean_calls = [ + c + for c in patch_capture.call_args_list + if c[0] + and c[0][0] == "$feature_flag_called" + and c[1]["properties"]["$feature_flag"] == "boolean-flag" + ] + self.assertEqual(len(boolean_calls), 1) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_get_flag_payload_does_not_fire_event(self, patch_capture, patch_flags): + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + + self.assertEqual(flags.get_flag_payload("variant-flag"), {"key": "value"}) + self.assertIsNone(flags.get_flag_payload("missing-flag")) + + feature_flag_called = [ + c + for c in patch_capture.call_args_list + if c[0] and c[0][0] == "$feature_flag_called" + ] + self.assertEqual(len(feature_flag_called), 0) + + @mock.patch("posthog.client.flags") + def test_forwards_flag_keys_to_request(self, patch_flags): + patch_flags.return_value = _flags_response_fixture() + + self.client.evaluate_flags("user-1", flag_keys=["boolean-flag", "variant-flag"]) + + kwargs = patch_flags.call_args.kwargs + self.assertEqual( + kwargs.get("flag_keys_to_evaluate"), + ["boolean-flag", "variant-flag"], + ) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_empty_distinct_id_returns_empty_snapshot_without_events( + self, patch_capture, patch_flags + ): + flags = self.client.evaluate_flags() # no distinct_id, no context + self.assertEqual(flags.keys, []) + flags.is_enabled("any-flag") + flags.get_flag("any-flag") + + feature_flag_called = [ + c + for c in patch_capture.call_args_list + if c[0] and c[0][0] == "$feature_flag_called" + ] + self.assertEqual(len(feature_flag_called), 0) + + +class TestEvaluateFlagsFiltering(unittest.TestCase): + def setUp(self): + self.client = Client(FAKE_TEST_API_KEY) + + def tearDown(self): + self.client.shutdown() + + @mock.patch("posthog.client.flags") + def test_only_accessed_returns_only_accessed_flags(self, patch_flags): + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + flags.is_enabled("boolean-flag") + flags.get_flag("variant-flag") + + accessed = flags.only_accessed() + self.assertEqual(sorted(accessed.keys), ["boolean-flag", "variant-flag"]) + + @mock.patch("posthog.client.flags") + def test_only_accessed_returns_empty_when_no_flags_accessed(self, patch_flags): + # The method honors its name: nothing accessed → empty snapshot, no fallback. + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + + accessed = flags.only_accessed() + + self.assertEqual(accessed.keys, []) + + @mock.patch("posthog.client.flags") + def test_only_drops_unknown_keys_with_warning(self, patch_flags): + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + + with self.assertLogs("posthog", level="WARNING") as logs: + only = flags.only(["boolean-flag", "does-not-exist"]) + + self.assertEqual(only.keys, ["boolean-flag"]) + self.assertTrue(any("does-not-exist" in m for m in logs.output)) + + @mock.patch("posthog.client.flags") + def test_filtered_snapshots_do_not_back_propagate_access(self, patch_flags): + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + flags.is_enabled("boolean-flag") + filtered = flags.only_accessed() + + filtered.is_enabled("variant-flag") + + self.assertEqual(flags.only_accessed().keys, ["boolean-flag"]) + + +class TestCaptureWithFlagsSnapshot(unittest.TestCase): + def setUp(self): + self.client = Client(FAKE_TEST_API_KEY) + + def tearDown(self): + self.client.shutdown() + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "_enqueue") + def test_capture_with_flags_attaches_feature_properties( + self, patch_enqueue, patch_flags + ): + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + + self.client.capture("page_viewed", distinct_id="user-1", flags=flags) + + # Find the page_viewed enqueue (skip $feature_flag_called events from access) + page_viewed = next( + ( + call + for call in patch_enqueue.call_args_list + if call[0][0]["event"] == "page_viewed" + ), + None, + ) + self.assertIsNotNone(page_viewed) + properties = page_viewed[0][0]["properties"] + self.assertEqual(properties["$feature/variant-flag"], "variant-value") + self.assertEqual(properties["$feature/boolean-flag"], True) + self.assertEqual(properties["$feature/disabled-flag"], False) + self.assertEqual( + sorted(properties["$active_feature_flags"]), + ["boolean-flag", "variant-flag"], + ) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "_enqueue") + def test_capture_with_only_accessed_attaches_only_those_flags( + self, patch_enqueue, patch_flags + ): + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + flags.is_enabled("boolean-flag") + + self.client.capture( + "page_viewed", distinct_id="user-1", flags=flags.only_accessed() + ) + + page_viewed = next( + ( + call + for call in patch_enqueue.call_args_list + if call[0][0]["event"] == "page_viewed" + ), + None, + ) + properties = page_viewed[0][0]["properties"] + self.assertEqual(properties["$feature/boolean-flag"], True) + self.assertNotIn("$feature/variant-flag", properties) + self.assertNotIn("$feature/disabled-flag", properties) + self.assertEqual(properties["$active_feature_flags"], ["boolean-flag"]) + + @mock.patch("posthog.client.flags") + def test_capture_with_flags_does_not_make_extra_flags_request(self, patch_flags): + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + calls_before = patch_flags.call_count + + self.client.capture("page_viewed", distinct_id="user-1", flags=flags) + + self.assertEqual(patch_flags.call_count, calls_before) + + @mock.patch("posthog.client.flags") + def test_capture_exception_forwards_flags_snapshot(self, patch_flags): + # Auto/manual exception captures should be able to attach a flags snapshot the + # same way capture() does, so $exception events carry the same flag context. + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + + with mock.patch.object(self.client, "capture") as inner_capture: + try: + raise ValueError("boom") + except ValueError as exc: + self.client.capture_exception(exc, distinct_id="user-1", flags=flags) + + self.assertEqual(inner_capture.call_count, 1) + forwarded = inner_capture.call_args.kwargs.get("flags") + self.assertIs(forwarded, flags) + + @mock.patch("posthog.client.flags") + def test_capture_warns_and_uses_flags_when_both_flags_and_send_feature_flags_set( + self, patch_flags + ): + # `flags` always wins regardless of `send_feature_flags`. We log a warning so + # the precedence isn't surprising when both are provided. + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + calls_before = patch_flags.call_count + + with self.assertLogs("posthog", level="WARNING") as logs: + self.client.capture( + "page_viewed", + distinct_id="user-1", + flags=flags, + send_feature_flags=True, + ) + + self.assertEqual(patch_flags.call_count, calls_before) + self.assertTrue( + any( + "Both `flags` and `send_feature_flags` were passed" in m + for m in logs.output + ) + ) + + +class TestDeprecationWarnings(unittest.TestCase): + def setUp(self): + self.client = Client(FAKE_TEST_API_KEY) + + def tearDown(self): + self.client.shutdown() + + @parameterized.expand( + [ + ("feature_enabled", "feature_enabled", ("boolean-flag", "user-1"), {}), + ("get_feature_flag", "get_feature_flag", ("boolean-flag", "user-1"), {}), + ( + "get_feature_flag_payload", + "get_feature_flag_payload", + ("variant-flag", "user-1"), + {}, + ), + ] + ) + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_legacy_single_flag_methods_emit_deprecation_warning( + self, _name, method_name, args, kwargs, patch_capture, patch_flags + ): + patch_flags.return_value = _flags_response_fixture() + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + getattr(self.client, method_name)(*args, **kwargs) + + deprecation = [ + w + for w in caught + if issubclass(w.category, DeprecationWarning) + and method_name in str(w.message) + ] + self.assertEqual(len(deprecation), 1) + self.assertIn("evaluate_flags", str(deprecation[0].message)) + + @mock.patch("posthog.client.flags") + def test_capture_send_feature_flags_emits_deprecation_warning(self, patch_flags): + patch_flags.return_value = _flags_response_fixture() + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + self.client.capture( + "page_viewed", distinct_id="user-1", send_feature_flags=True + ) + + deprecation = [ + w + for w in caught + if issubclass(w.category, DeprecationWarning) + and "send_feature_flags" in str(w.message) + ] + self.assertEqual(len(deprecation), 1) + self.assertIn("evaluate_flags", str(deprecation[0].message)) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_feature_enabled_does_not_cascade_deprecation_warnings( + self, patch_capture, patch_flags + ): + # `feature_enabled` calls `_get_feature_flag_result` directly so the user only + # sees one warning, not three (one each from feature_enabled → + # get_feature_flag → get_feature_flag_result if it had one). + patch_flags.return_value = _flags_response_fixture() + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + self.client.feature_enabled("boolean-flag", "user-1") + + deprecation = [w for w in caught if issubclass(w.category, DeprecationWarning)] + self.assertEqual(len(deprecation), 1) + + +if __name__ == "__main__": + unittest.main()