From c6d12445d95842518f1f258c713a8fd48833c3af Mon Sep 17 00:00:00 2001 From: dylan Date: Mon, 27 Apr 2026 12:10:37 -0700 Subject: [PATCH 1/6] feat: add evaluate_flags() API for single-call flag evaluation Introduce posthog.evaluate_flags(distinct_id, ...) returning a FeatureFlagEvaluations snapshot. Branch on .is_enabled() / .get_flag() and pass the snapshot to capture() via a new flags option so events carry the exact values the code branched on, with no extra /flags request per capture. Filtering helpers .only_accessed() and .only([keys]) narrow the flag set attached to events. Pass flag_keys=[...] to evaluate_flags() to scope the underlying /flags request. Local evaluation is transparent. Deprecation (Phase 2 of the RFC): - feature_enabled, get_feature_flag, get_feature_flag_payload, and capture(send_feature_flags=...) now emit DeprecationWarning. - The deprecated surface remains functional and will be removed in the next major version. Generated-By: PostHog Code Task-Id: b8a45b11-b41c-4995-8622-acea525e7703 --- .sampo/changesets/evaluate-flags-api.md | 16 + posthog/__init__.py | 56 +++ posthog/args.py | 17 +- posthog/client.py | 460 +++++++++++++++++++----- posthog/feature_flag_evaluations.py | 261 ++++++++++++++ posthog/test/test_evaluate_flags.py | 427 ++++++++++++++++++++++ 6 files changed, 1134 insertions(+), 103 deletions(-) create mode 100644 .sampo/changesets/evaluate-flags-api.md create mode 100644 posthog/feature_flag_evaluations.py create mode 100644 posthog/test/test_evaluate_flags.py diff --git a/.sampo/changesets/evaluate-flags-api.md b/.sampo/changesets/evaluate-flags-api.md new file mode 100644 index 00000000..9c5ee70b --- /dev/null +++ b/.sampo/changesets/evaluate-flags-api.md @@ -0,0 +1,16 @@ +--- +"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. + +`feature_enabled()`, `get_feature_flag()`, `get_feature_flag_payload()`, and `capture(send_feature_flags=...)` now emit `DeprecationWarning` and will be removed in the next major version. diff --git a/posthog/__init__.py b/posthog/__init__.py index b594c01a..808f47d9 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,59 @@ 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 to scope the underlying ``/flags`` + request to a subset. + + 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 1b7f0a34..293b1295 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 -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, List, Optional, Union from uuid import uuid4 from dateutil.tz import tzutc @@ -33,6 +34,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, @@ -631,6 +637,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) @@ -656,70 +663,85 @@ 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}" + if flags_snapshot is not None: + # Prefer the explicit ``flags`` snapshot — it guarantees the event carries + # the same values the developer branched on, with no additional network call. + 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=3, ) + 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} @@ -1555,7 +1577,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, + ) + # Call the internal method directly to avoid emitting nested deprecation warnings + # from get_feature_flag → get_feature_flag_result. + flag_result = self._get_feature_flag_result( key, distinct_id, groups=groups, @@ -1566,6 +1598,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 @@ -1815,7 +1848,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, + ) + # Call the internal method directly to bypass the public ``get_feature_flag_result`` + # so the user only sees a single deprecation warning per call. + feature_flag_result = self._get_feature_flag_result( key, distinct_id, groups=groups, @@ -1910,6 +1953,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 " @@ -1976,6 +2027,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)}" ) @@ -1985,43 +2086,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: @@ -2190,6 +2265,193 @@ 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() + + 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 + 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, + ) + request_id = response.get("requestId") + raw_evaluated_at = response.get("evaluatedAt") + evaluated_at = ( + raw_evaluated_at if isinstance(raw_evaluated_at, int) else None + ) + 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 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, + ) + + _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..6f4d615e --- /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, + flag_definitions_loaded_at: Optional[int] = None, + 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._flag_definitions_loaded_at = flag_definitions_loaded_at + 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. + + **Empty-access fallback:** if no flags have been accessed yet, this method logs + a warning and returns a copy with *all* evaluated flags. This avoids silently + dropping every flag from the captured event when ``only_accessed()`` is called + out of order. Pre-access before calling this if you want a guaranteed-empty + result. + """ + if not self._accessed: + self._host.log_warning( + "FeatureFlagEvaluations.only_accessed() was called before any flags were accessed — " + "attaching all evaluated flags as a fallback. See " + "https://posthog.com/docs/feature-flags/server-sdks for details." + ) + return self._clone_with(self._flags) + 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, + flag_definitions_loaded_at=self._flag_definitions_loaded_at, + # 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 flag.locally_evaluated and self._flag_definitions_loaded_at is not None: + properties["$feature_flag_definitions_loaded_at"] = ( + self._flag_definitions_loaded_at + ) + + 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 + if flag is None: + properties["$feature_flag_error"] = "flag_missing" + + 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..c13c002a --- /dev/null +++ b/posthog/test/test_evaluate_flags.py @@ -0,0 +1,427 @@ +import unittest +import warnings + +import mock + +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) + + @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) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_is_enabled_returns_correct_values_and_fires_events( + self, patch_capture, patch_flags + ): + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + + self.assertTrue(flags.is_enabled("boolean-flag")) + self.assertFalse(flags.is_enabled("disabled-flag")) + self.assertTrue(flags.is_enabled("variant-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), 3) + keys = sorted(c[1]["properties"]["$feature_flag"] for c in feature_flag_called) + self.assertEqual(keys, ["boolean-flag", "disabled-flag", "variant-flag"]) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_get_flag_returns_variant_or_bool_with_full_metadata( + self, patch_capture, patch_flags + ): + patch_flags.return_value = _flags_response_fixture() + flags = self.client.evaluate_flags("user-1") + + self.assertEqual(flags.get_flag("variant-flag"), "variant-value") + self.assertEqual(flags.get_flag("boolean-flag"), True) + self.assertEqual(flags.get_flag("disabled-flag"), False) + 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.assertEqual( + by_key["variant-flag"]["$feature_flag_response"], "variant-value" + ) + self.assertEqual(by_key["variant-flag"]["$feature_flag_id"], 2) + self.assertEqual(by_key["variant-flag"]["$feature_flag_version"], 23) + self.assertEqual( + by_key["variant-flag"]["$feature_flag_reason"], "Matched condition set 3" + ) + self.assertEqual( + by_key["variant-flag"]["$feature_flag_request_id"], "request-id-1" + ) + self.assertFalse(by_key["variant-flag"]["locally_evaluated"]) + + 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_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_falls_back_with_warning_when_empty(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: + accessed = flags.only_accessed() + + self.assertEqual( + sorted(accessed.keys), + ["boolean-flag", "disabled-flag", "variant-flag"], + ) + self.assertTrue( + any( + "only_accessed() was called before any flags were accessed" in m + for m in logs.output + ) + ) + + @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) + + +class TestDeprecationWarnings(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, "capture") + def test_feature_enabled_emits_deprecation_warning( + self, patch_capture, patch_flags + ): + 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") + + self.assertTrue( + any( + issubclass(w.category, DeprecationWarning) + and "feature_enabled" in str(w.message) + for w in caught + ) + ) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_get_feature_flag_emits_deprecation_warning( + self, patch_capture, patch_flags + ): + patch_flags.return_value = _flags_response_fixture() + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + self.client.get_feature_flag("boolean-flag", "user-1") + + self.assertTrue( + any( + issubclass(w.category, DeprecationWarning) + and "get_feature_flag" in str(w.message) + for w in caught + ) + ) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_get_feature_flag_payload_emits_deprecation_warning( + self, patch_capture, patch_flags + ): + patch_flags.return_value = _flags_response_fixture() + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + self.client.get_feature_flag_payload("variant-flag", "user-1") + + self.assertTrue( + any( + issubclass(w.category, DeprecationWarning) + and "get_feature_flag_payload" in str(w.message) + for w in caught + ) + ) + + @mock.patch("posthog.client.flags") + def test_capture_send_feature_flags_true_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 + ) + + self.assertTrue( + any( + issubclass(w.category, DeprecationWarning) + and "send_feature_flags" in str(w.message) + for w in caught + ) + ) + + @mock.patch("posthog.client.flags") + @mock.patch.object(Client, "capture") + def test_feature_enabled_does_not_emit_nested_warnings( + self, patch_capture, patch_flags + ): + """feature_enabled should emit exactly one warning, not cascade through + get_feature_flag → get_feature_flag_result. + """ + 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_warnings = [ + w for w in caught if issubclass(w.category, DeprecationWarning) + ] + self.assertEqual(len(deprecation_warnings), 1) + + +if __name__ == "__main__": + unittest.main() From e8984dffe3016b5652ebbc6f7c3790e9ca691efa Mon Sep 17 00:00:00 2001 From: dylan Date: Mon, 27 Apr 2026 12:14:03 -0700 Subject: [PATCH 2/6] test: use unittest.mock instead of third-party mock `mock` is not in the project's test dependencies on CI. Generated-By: PostHog Code Task-Id: b8a45b11-b41c-4995-8622-acea525e7703 --- posthog/test/test_evaluate_flags.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/posthog/test/test_evaluate_flags.py b/posthog/test/test_evaluate_flags.py index c13c002a..8716fb9f 100644 --- a/posthog/test/test_evaluate_flags.py +++ b/posthog/test/test_evaluate_flags.py @@ -1,7 +1,6 @@ import unittest import warnings - -import mock +from unittest import mock from posthog.client import Client from posthog.feature_flag_evaluations import FeatureFlagEvaluations From 17023b25de8126f8e0873f8eb8f53f89d66f862b Mon Sep 17 00:00:00 2001 From: dylan Date: Mon, 27 Apr 2026 12:22:26 -0700 Subject: [PATCH 3/6] =?UTF-8?q?revert:=20drop=20deprecation=20warnings=20(?= =?UTF-8?q?Phase=202)=20=E2=80=94=20split=20into=20follow-up=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 deprecation warnings on `feature_enabled`, `get_feature_flag`, `get_feature_flag_payload`, and `capture(send_feature_flags=...)` are moved to a separate PR so this minor ships only the new API. Gives users one minor to migrate before runtime warnings start. The deprecated methods are restored to their original implementations (no longer need to bypass each other to avoid cascading warnings). Generated-By: PostHog Code Task-Id: b8a45b11-b41c-4995-8622-acea525e7703 --- .sampo/changesets/evaluate-flags-api.md | 2 +- posthog/client.py | 42 +--------- posthog/test/test_evaluate_flags.py | 105 ------------------------ 3 files changed, 3 insertions(+), 146 deletions(-) diff --git a/.sampo/changesets/evaluate-flags-api.md b/.sampo/changesets/evaluate-flags-api.md index 9c5ee70b..40739f6e 100644 --- a/.sampo/changesets/evaluate-flags-api.md +++ b/.sampo/changesets/evaluate-flags-api.md @@ -13,4 +13,4 @@ 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. -`feature_enabled()`, `get_feature_flag()`, `get_feature_flag_payload()`, and `capture(send_feature_flags=...)` now emit `DeprecationWarning` and will be removed in the next major version. +Existing `feature_enabled()`, `get_feature_flag()`, `get_feature_flag_payload()`, and `capture(send_feature_flags=...)` continue to work unchanged. They will be deprecated in a follow-up minor and removed in the next major version. diff --git a/posthog/client.py b/posthog/client.py index 293b1295..656f6972 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -675,15 +675,6 @@ def capture( 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=3, - ) try: if flag_options["only_evaluate_locally"] is True: # Local evaluation explicitly requested @@ -1577,17 +1568,7 @@ def feature_enabled( Category: Feature flags """ - 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, - ) - # Call the internal method directly to avoid emitting nested deprecation warnings - # from get_feature_flag → get_feature_flag_result. - flag_result = self._get_feature_flag_result( + response = self.get_feature_flag( key, distinct_id, groups=groups, @@ -1598,7 +1579,6 @@ 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 @@ -1848,17 +1828,7 @@ def get_feature_flag( Category: Feature flags """ - 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, - ) - # Call the internal method directly to bypass the public ``get_feature_flag_result`` - # so the user only sees a single deprecation warning per call. - feature_flag_result = self._get_feature_flag_result( + feature_flag_result = self.get_feature_flag_result( key, distinct_id, groups=groups, @@ -1953,14 +1923,6 @@ 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 " diff --git a/posthog/test/test_evaluate_flags.py b/posthog/test/test_evaluate_flags.py index 8716fb9f..17d1742c 100644 --- a/posthog/test/test_evaluate_flags.py +++ b/posthog/test/test_evaluate_flags.py @@ -1,5 +1,4 @@ import unittest -import warnings from unittest import mock from posthog.client import Client @@ -318,109 +317,5 @@ def test_capture_with_flags_does_not_make_extra_flags_request(self, patch_flags) self.assertEqual(patch_flags.call_count, calls_before) -class TestDeprecationWarnings(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, "capture") - def test_feature_enabled_emits_deprecation_warning( - self, patch_capture, patch_flags - ): - 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") - - self.assertTrue( - any( - issubclass(w.category, DeprecationWarning) - and "feature_enabled" in str(w.message) - for w in caught - ) - ) - - @mock.patch("posthog.client.flags") - @mock.patch.object(Client, "capture") - def test_get_feature_flag_emits_deprecation_warning( - self, patch_capture, patch_flags - ): - patch_flags.return_value = _flags_response_fixture() - - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - self.client.get_feature_flag("boolean-flag", "user-1") - - self.assertTrue( - any( - issubclass(w.category, DeprecationWarning) - and "get_feature_flag" in str(w.message) - for w in caught - ) - ) - - @mock.patch("posthog.client.flags") - @mock.patch.object(Client, "capture") - def test_get_feature_flag_payload_emits_deprecation_warning( - self, patch_capture, patch_flags - ): - patch_flags.return_value = _flags_response_fixture() - - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - self.client.get_feature_flag_payload("variant-flag", "user-1") - - self.assertTrue( - any( - issubclass(w.category, DeprecationWarning) - and "get_feature_flag_payload" in str(w.message) - for w in caught - ) - ) - - @mock.patch("posthog.client.flags") - def test_capture_send_feature_flags_true_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 - ) - - self.assertTrue( - any( - issubclass(w.category, DeprecationWarning) - and "send_feature_flags" in str(w.message) - for w in caught - ) - ) - - @mock.patch("posthog.client.flags") - @mock.patch.object(Client, "capture") - def test_feature_enabled_does_not_emit_nested_warnings( - self, patch_capture, patch_flags - ): - """feature_enabled should emit exactly one warning, not cascade through - get_feature_flag → get_feature_flag_result. - """ - 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_warnings = [ - w for w in caught if issubclass(w.category, DeprecationWarning) - ] - self.assertEqual(len(deprecation_warnings), 1) - - if __name__ == "__main__": unittest.main() From 425dbb5e6b9aebd9455ee0204b839456c9cb9693 Mon Sep 17 00:00:00 2001 From: dylan Date: Mon, 27 Apr 2026 15:23:22 -0700 Subject: [PATCH 4/6] fix: address review feedback on evaluate_flags() PR - Add `tearDown` to `TestEvaluateFlagsRemote` so the Client's background consumer thread is joined between tests, matching the pattern in the other test classes in this file. - Remove the dead `flag_definitions_loaded_at` constructor parameter from `FeatureFlagEvaluations`. The Python poller doesn't currently expose a definitions-loaded timestamp, so the parameter was always None and the gated branch in `_record_access` never fired. Trim it rather than leaving a confusing no-op; can be re-added with a real data source later. - Convert the two flag-type-variation tests to parameterized form. `test_is_enabled` and `test_get_flag_known_keys` now run as independently-named cases per flag type, and the missing-key behavior is split into its own focused test. Generated-By: PostHog Code Task-Id: b8a45b11-b41c-4995-8622-acea525e7703 --- posthog/feature_flag_evaluations.py | 7 -- posthog/test/test_evaluate_flags.py | 101 ++++++++++++++++++++-------- 2 files changed, 73 insertions(+), 35 deletions(-) diff --git a/posthog/feature_flag_evaluations.py b/posthog/feature_flag_evaluations.py index 6f4d615e..62eac2f7 100644 --- a/posthog/feature_flag_evaluations.py +++ b/posthog/feature_flag_evaluations.py @@ -66,7 +66,6 @@ def __init__( disable_geoip: Optional[bool] = None, request_id: Optional[str] = None, evaluated_at: Optional[int] = None, - flag_definitions_loaded_at: Optional[int] = None, accessed: Optional[Set[str]] = None, ) -> None: """Internal — instances are created by the SDK via ``Client.evaluate_flags()``.""" @@ -77,7 +76,6 @@ def __init__( self._disable_geoip = disable_geoip self._request_id = request_id self._evaluated_at = evaluated_at - self._flag_definitions_loaded_at = flag_definitions_loaded_at self._accessed: Set[str] = set(accessed) if accessed is not None else set() def is_enabled(self, key: str) -> bool: @@ -199,7 +197,6 @@ def _clone_with( disable_geoip=self._disable_geoip, request_id=self._request_id, evaluated_at=self._evaluated_at, - flag_definitions_loaded_at=self._flag_definitions_loaded_at, # 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. @@ -239,10 +236,6 @@ def _record_access(self, key: str) -> None: properties["$feature_flag_version"] = flag.version if flag.reason: properties["$feature_flag_reason"] = flag.reason - if flag.locally_evaluated and self._flag_definitions_loaded_at is not None: - properties["$feature_flag_definitions_loaded_at"] = ( - self._flag_definitions_loaded_at - ) if self._request_id: properties["$feature_flag_request_id"] = self._request_id diff --git a/posthog/test/test_evaluate_flags.py b/posthog/test/test_evaluate_flags.py index 17d1742c..ce6690ac 100644 --- a/posthog/test/test_evaluate_flags.py +++ b/posthog/test/test_evaluate_flags.py @@ -1,6 +1,8 @@ import unittest 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 @@ -43,6 +45,9 @@ 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() @@ -64,38 +69,91 @@ def test_does_not_fire_events_for_unaccessed_flags( ] 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_returns_correct_values_and_fires_events( - self, patch_capture, patch_flags - ): + 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.assertTrue(flags.is_enabled("boolean-flag")) - self.assertFalse(flags.is_enabled("disabled-flag")) - self.assertTrue(flags.is_enabled("variant-flag")) + self.assertEqual(flags.is_enabled(key), expected) - feature_flag_called = [ + flag_called = [ c for c in patch_capture.call_args_list - if c[0] and c[0][0] == "$feature_flag_called" + 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}, + ), ] - self.assertEqual(len(feature_flag_called), 3) - keys = sorted(c[1]["properties"]["$feature_flag"] for c in feature_flag_called) - self.assertEqual(keys, ["boolean-flag", "disabled-flag", "variant-flag"]) + ) + @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_returns_variant_or_bool_with_full_metadata( + 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.assertEqual(flags.get_flag("variant-flag"), "variant-value") - self.assertEqual(flags.get_flag("boolean-flag"), True) - self.assertEqual(flags.get_flag("disabled-flag"), False) self.assertIsNone(flags.get_flag("missing-flag")) by_key = { @@ -103,19 +161,6 @@ def test_get_flag_returns_variant_or_bool_with_full_metadata( for c in patch_capture.call_args_list if c[0] and c[0][0] == "$feature_flag_called" } - self.assertEqual( - by_key["variant-flag"]["$feature_flag_response"], "variant-value" - ) - self.assertEqual(by_key["variant-flag"]["$feature_flag_id"], 2) - self.assertEqual(by_key["variant-flag"]["$feature_flag_version"], 23) - self.assertEqual( - by_key["variant-flag"]["$feature_flag_reason"], "Matched condition set 3" - ) - self.assertEqual( - by_key["variant-flag"]["$feature_flag_request_id"], "request-id-1" - ) - self.assertFalse(by_key["variant-flag"]["locally_evaluated"]) - self.assertIsNone(by_key["missing-flag"]["$feature_flag_response"]) self.assertEqual(by_key["missing-flag"]["$feature_flag_error"], "flag_missing") From eda573de858f19357c1ee7e00be0565172215c13 Mon Sep 17 00:00:00 2001 From: dylan Date: Tue, 28 Apr 2026 11:41:22 -0700 Subject: [PATCH 5/6] feat: deprecate legacy single-flag methods (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per reviewer feedback, ship Phase 2 in this PR alongside Phase 1 instead of splitting it into a follow-up. The deprecated methods continue to work — they just emit a `DeprecationWarning` pointing at `evaluate_flags()`: - `feature_enabled()` - `get_feature_flag()` - `get_feature_flag_payload()` - `capture(send_feature_flags=...)` (only when truthy) `feature_enabled` and `get_feature_flag` are restructured to call `_get_feature_flag_result` directly instead of routing through each other, so a single user-level call emits exactly one warning instead of cascading. Tests cover each warning's emission and the no-cascade behavior. Existing tests that use the legacy methods will now generate DeprecationWarnings but otherwise pass unchanged. Generated-By: PostHog Code Task-Id: b8a45b11-b41c-4995-8622-acea525e7703 --- .sampo/changesets/evaluate-flags-api.md | 2 +- posthog/client.py | 41 ++++++++++++- posthog/test/test_evaluate_flags.py | 77 +++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 3 deletions(-) diff --git a/.sampo/changesets/evaluate-flags-api.md b/.sampo/changesets/evaluate-flags-api.md index b35936e7..1e37a39c 100644 --- a/.sampo/changesets/evaluate-flags-api.md +++ b/.sampo/changesets/evaluate-flags-api.md @@ -13,4 +13,4 @@ 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. -Existing `feature_enabled()`, `get_feature_flag()`, `get_feature_flag_payload()`, and `capture(send_feature_flags=...)` continue to work unchanged. They will be deprecated in a follow-up minor and removed in the next major version. +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/client.py b/posthog/client.py index b0be4883..dd12b2d8 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -681,6 +681,14 @@ def capture( 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 @@ -1574,7 +1582,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, @@ -1585,6 +1603,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 @@ -1834,7 +1853,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, @@ -1929,6 +1958,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 " diff --git a/posthog/test/test_evaluate_flags.py b/posthog/test/test_evaluate_flags.py index 5e26a983..286eba5e 100644 --- a/posthog/test/test_evaluate_flags.py +++ b/posthog/test/test_evaluate_flags.py @@ -1,4 +1,5 @@ import unittest +import warnings from unittest import mock from parameterized import parameterized @@ -408,5 +409,81 @@ def test_capture_warns_and_uses_flags_when_both_flags_and_send_feature_flags_set ) +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() From db6d3c097ac0ef990702b6512458fb85fe17731b Mon Sep 17 00:00:00 2001 From: dylan Date: Tue, 28 Apr 2026 11:47:16 -0700 Subject: [PATCH 6/6] fix: forward `flags` kwarg through capture_exception() to capture() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per PR review feedback (manoel): manual exception captures should be able to attach a `FeatureFlagEvaluations` snapshot the same way `capture()` can, so `\$exception` events carry the same flag context as the rest of the request's events. `capture_exception` already accepted `**kwargs` and forwarded select ones to `capture()` — `flags` was just missing from the forwarded set. This doesn't yet solve the wider question of how *auto*-captured exceptions (sys.excepthook, context-block exception handler) attach flags — that requires a separate mechanism (likely context-stashed flags) and is a follow-up. Generated-By: PostHog Code Task-Id: b8a45b11-b41c-4995-8622-acea525e7703 --- posthog/client.py | 6 +++++- posthog/test/test_evaluate_flags.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/posthog/client.py b/posthog/client.py index dd12b2d8..f9570540 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1007,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: @@ -1024,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. @@ -1106,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, ) diff --git a/posthog/test/test_evaluate_flags.py b/posthog/test/test_evaluate_flags.py index 286eba5e..4c360440 100644 --- a/posthog/test/test_evaluate_flags.py +++ b/posthog/test/test_evaluate_flags.py @@ -382,6 +382,23 @@ def test_capture_with_flags_does_not_make_extra_flags_request(self, patch_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