diff --git a/posthog/__init__.py b/posthog/__init__.py index b594c01a..4e3c2e9e 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -617,6 +617,7 @@ def get_all_flags( disable_geoip=None, # type: Optional[bool] device_id=None, # type: Optional[str] flag_keys_to_evaluate=None, # type: Optional[list[str]] + send_feature_flag_events=False, # type: bool ) -> Optional[dict[str, FeatureFlag]]: """ Get all flags for a given user. @@ -629,6 +630,8 @@ def get_all_flags( only_evaluate_locally: Whether to evaluate only locally disable_geoip: Whether to disable GeoIP lookup flag_keys_to_evaluate: Optional list of flag keys to evaluate (evaluates all if None) + send_feature_flag_events: If True, emit a `$feature_flag_called` event for each + resolved flag. Defaults to False to preserve historical behavior. Details: Flags are key-value pairs where the key is the flag key and the value is the flag variant, or True, or False. @@ -652,6 +655,7 @@ def get_all_flags( disable_geoip=disable_geoip, device_id=device_id, flag_keys_to_evaluate=flag_keys_to_evaluate, + send_feature_flag_events=send_feature_flag_events, ) @@ -756,6 +760,7 @@ def get_all_flags_and_payloads( disable_geoip=None, # type: Optional[bool] device_id=None, # type: Optional[str] flag_keys_to_evaluate=None, # type: Optional[list[str]] + send_feature_flag_events=False, # type: bool ) -> FlagsAndPayloads: return _proxy( "get_all_flags_and_payloads", @@ -767,6 +772,49 @@ def get_all_flags_and_payloads( disable_geoip=disable_geoip, device_id=device_id, flag_keys_to_evaluate=flag_keys_to_evaluate, + send_feature_flag_events=send_feature_flag_events, + ) + + +def get_feature_flags( + keys, # type: list[str] + distinct_id, + groups=None, # type: Optional[dict] + person_properties=None, # type: Optional[dict] + group_properties=None, # type: Optional[dict] + only_evaluate_locally=False, + send_feature_flag_events=True, + disable_geoip=None, # type: Optional[bool] + device_id=None, # type: Optional[str] +): + # type: (...) -> dict[str, Optional[FeatureFlagResult]] + """ + Evaluate a subset of feature flags in one bulk pass and return a FeatureFlagResult per requested key. + + Evaluates each key locally first (when local evaluation is enabled) and then makes at most one remote + `/flags` request for any keys that could not be resolved locally. Emits `$feature_flag_called` events + per resolved flag (deduped across calls via the same cache as `get_feature_flag`). + + Example: + ```python + from posthog import get_feature_flags + results = get_feature_flags(['beta-feature', 'new-dashboard'], 'distinct_id') + if results['beta-feature'] and results['beta-feature'].enabled: + # Use the variant and payload + print(results['beta-feature'].variant) + ``` + """ + return _proxy( + "get_feature_flags", + keys=keys, + distinct_id=distinct_id, + groups=groups or {}, + person_properties=person_properties or {}, + group_properties=group_properties or {}, + only_evaluate_locally=only_evaluate_locally, + send_feature_flag_events=send_feature_flag_events, + disable_geoip=disable_geoip, + device_id=device_id, ) diff --git a/posthog/client.py b/posthog/client.py index c0c5491c..2d63c752 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1821,6 +1821,254 @@ def get_feature_flag( ) return feature_flag_result.get_value() if feature_flag_result else None + def _get_feature_flag_results( + self, + keys: list[str], + distinct_id: ID_TYPES, + *, + groups=None, + person_properties=None, + group_properties=None, + only_evaluate_locally: bool = False, + send_feature_flag_events: bool = True, + disable_geoip: Optional[bool] = None, + device_id: Optional[str] = None, + ) -> dict[str, Optional[FeatureFlagResult]]: + # Deduplicate requested keys while preserving order. + unique_keys: list[str] = list(dict.fromkeys(keys)) + + if self.disabled: + return {key: None for key in unique_keys} + + person_properties, group_properties = ( + self._add_local_person_and_group_properties( + distinct_id, + groups or {}, + person_properties or {}, + group_properties or {}, + ) + ) + groups = groups or {} + person_properties = person_properties or {} + group_properties = group_properties or {} + + if device_id is None: + device_id = get_context_device_id() + + results: dict[str, Optional[FeatureFlagResult]] = {} + # Per-key metadata captured during evaluation so event emission mirrors + # the single-flag path's properties (request id, reason, version, id). + event_meta: dict[str, dict[str, Any]] = {} + still_unresolved: list[str] = [] + + for key in unique_keys: + flag_value = self._locally_evaluate_flag( + key, + distinct_id, + groups, + person_properties, + group_properties, + device_id, + ) + if flag_value is not None: + payload = self._compute_payload_locally(key, flag_value) + flag_result = FeatureFlagResult.from_value_and_payload( + key, flag_value, payload + ) + results[key] = flag_result + event_meta[key] = { + "locally_evaluated": True, + "payload": payload, + "request_id": None, + "evaluated_at": None, + "flag_details": None, + "feature_flag_error": None, + } + if self.flag_cache and flag_result: + self.flag_cache.set_cached_flag( + distinct_id, key, flag_result, self.flag_definition_version + ) + else: + still_unresolved.append(key) + + if still_unresolved and only_evaluate_locally: + if self.feature_flags is None: + self.log.warning( + "[FEATURE FLAGS] Local evaluation called but feature flag definitions are not loaded yet. " + "Returning None. You can call load_feature_flags() to load flags explicitly." + ) + for key in still_unresolved: + results[key] = None + event_meta[key] = { + "locally_evaluated": False, + "payload": None, + "request_id": None, + "evaluated_at": None, + "flag_details": None, + "feature_flag_error": None, + } + elif still_unresolved: + shared_error: Optional[str] = None + resp_data: Optional[FlagsResponse] = None + try: + resp_data = self.get_flags_decision( + distinct_id, + groups, + person_properties, + group_properties, + disable_geoip, + flag_keys_to_evaluate=still_unresolved, + device_id=device_id, + ) + except QuotaLimitError as e: + self.log.warning(f"[FEATURE FLAGS] Quota limit exceeded: {e}") + shared_error = FeatureFlagError.QUOTA_LIMITED + except RequestsTimeout as e: + self.log.warning(f"[FEATURE FLAGS] Request timed out: {e}") + shared_error = FeatureFlagError.TIMEOUT + except RequestsConnectionError as e: + self.log.warning(f"[FEATURE FLAGS] Connection error: {e}") + shared_error = FeatureFlagError.CONNECTION_ERROR + except APIError as e: + self.log.warning(f"[FEATURE FLAGS] API error: {e}") + shared_error = FeatureFlagError.api_error(e.status) + except Exception as e: + self.log.exception(f"[FEATURE FLAGS] Unable to get flags remotely: {e}") + shared_error = FeatureFlagError.UNKNOWN_ERROR + + if resp_data is not None: + request_id = resp_data.get("requestId") + evaluated_at = resp_data.get("evaluatedAt") + errors_while_computing = resp_data.get( + "errorsWhileComputingFlags", False + ) + remote_flags = resp_data.get("flags") or {} + for key in still_unresolved: + flag_details = remote_flags.get(key) + flag_result = FeatureFlagResult.from_flag_details(flag_details) + results[key] = flag_result + errors: list[str] = [] + if errors_while_computing: + errors.append(FeatureFlagError.ERRORS_WHILE_COMPUTING) + if flag_details is None: + errors.append(FeatureFlagError.FLAG_MISSING) + event_meta[key] = { + "locally_evaluated": False, + "payload": flag_result.payload if flag_result else None, + "request_id": request_id, + "evaluated_at": evaluated_at, + "flag_details": flag_details, + "feature_flag_error": ",".join(errors) if errors else None, + } + if self.flag_cache and flag_result: + self.flag_cache.set_cached_flag( + distinct_id, + key, + flag_result, + self.flag_definition_version, + ) + else: + for key in still_unresolved: + stale_result = self._get_stale_flag_fallback(distinct_id, key) + results[key] = stale_result + event_meta[key] = { + "locally_evaluated": False, + "payload": stale_result.payload if stale_result else None, + "request_id": None, + "evaluated_at": None, + "flag_details": None, + "feature_flag_error": shared_error, + } + + if send_feature_flag_events: + for key in unique_keys: + meta = event_meta[key] + flag_result = results[key] + self._capture_feature_flag_called( + distinct_id, + key, + flag_result.get_value() if flag_result else None, + meta["payload"], + meta["locally_evaluated"], + groups, + disable_geoip, + meta["request_id"], + meta["evaluated_at"], + meta["flag_details"], + meta["feature_flag_error"], + ) + + return results + + def get_feature_flags( + self, + keys: list[str], + distinct_id, + *, + groups=None, + person_properties=None, + group_properties=None, + only_evaluate_locally=False, + send_feature_flag_events=True, + disable_geoip=None, + device_id: Optional[str] = None, + ) -> dict[str, Optional[FeatureFlagResult]]: + """ + Evaluate a subset of feature flags in one bulk pass and return a FeatureFlagResult + per requested key. + + Evaluates each key locally first (when local evaluation is enabled) and then makes + at most one remote `/flags` request for any keys that could not be resolved locally — + avoiding the N remote round trips of calling `get_feature_flag` per flag while + preserving the per-flag `$feature_flag_called` usage signal that `get_all_flags` loses. + + Events are emitted per resolved flag and deduped across calls using the same + `distinct_ids_feature_flags_reported` cache as the single-flag path, so a given + (distinct_id, key, value) tuple produces at most one event per process. + + Examples: + ```python + results = posthog.get_feature_flags( + ['new-dashboard', 'beta-search'], + 'user_123', + person_properties={'plan': 'pro'}, + ) + if results['new-dashboard'] and results['new-dashboard'].enabled: + # Do something differently for this user + payload = results['new-dashboard'].payload + ``` + + Args: + keys: The feature flag keys to evaluate. + distinct_id: The distinct ID of the user. + groups: A dictionary of group information. + person_properties: A dictionary of person properties. + group_properties: A dictionary of group properties. + only_evaluate_locally: If True, do not fall back to a remote request for keys + that cannot be resolved locally; those keys resolve to None. + send_feature_flag_events: Whether to emit `$feature_flag_called` events. + disable_geoip: Whether to disable GeoIP for this request. + device_id: The device ID for this request. + + Returns: + dict[str, Optional[FeatureFlagResult]]: A dict keyed by every requested flag key. + Keys that could not be resolved map to None. + + Category: + Feature flags + """ + return self._get_feature_flag_results( + keys, + distinct_id, + groups=groups, + person_properties=person_properties, + group_properties=group_properties, + only_evaluate_locally=only_evaluate_locally, + send_feature_flag_events=send_feature_flag_events, + disable_geoip=disable_geoip, + device_id=device_id, + ) + def _locally_evaluate_flag( self, key: str, @@ -2072,6 +2320,7 @@ def get_all_flags( disable_geoip=None, flag_keys_to_evaluate: Optional[list[str]] = None, device_id: Optional[str] = None, + send_feature_flag_events: bool = False, ) -> Optional[dict[str, Union[bool, str]]]: """ Get all feature flags for a user. @@ -2086,6 +2335,9 @@ def get_all_flags( flag_keys_to_evaluate: A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance. device_id: The device ID for this request. + send_feature_flag_events: If True, emit a `$feature_flag_called` event for each + resolved flag (matching `get_feature_flag` behavior). Defaults to False to + preserve the historical behavior of this method. Examples: ```python @@ -2104,6 +2356,7 @@ def get_all_flags( disable_geoip=disable_geoip, flag_keys_to_evaluate=flag_keys_to_evaluate, device_id=device_id, + send_feature_flag_events=send_feature_flag_events, ) return response["featureFlags"] @@ -2119,6 +2372,7 @@ def get_all_flags_and_payloads( disable_geoip=None, flag_keys_to_evaluate: Optional[list[str]] = None, device_id: Optional[str] = None, + send_feature_flag_events: bool = False, ) -> FlagsAndPayloads: """ Get all feature flags and their payloads for a user. @@ -2133,6 +2387,9 @@ def get_all_flags_and_payloads( flag_keys_to_evaluate: A list of specific flag keys to evaluate. If provided, only these flags will be evaluated, improving performance. device_id: The device ID for this request. + send_feature_flag_events: If True, emit a `$feature_flag_called` event for each + resolved flag (matching `get_feature_flag` behavior). Defaults to False to + preserve the historical behavior of this method. Examples: ```python @@ -2164,6 +2421,11 @@ def get_all_flags_and_payloads( device_id=device_id, ) + locally_evaluated_keys: set[str] = set( + (response.get("featureFlags") or {}).keys() + ) + decide_response: Optional[FlagsResponse] = None + if fallback_to_flags and not only_evaluate_locally: try: decide_response = self.get_flags_decision( @@ -2175,12 +2437,37 @@ def get_all_flags_and_payloads( flag_keys_to_evaluate=flag_keys_to_evaluate, device_id=device_id, ) - return to_flags_and_payloads(decide_response) + response = to_flags_and_payloads(decide_response) except Exception as e: self.log.exception( f"[FEATURE FLAGS] Unable to get feature flags and payloads: {e}" ) + if send_feature_flag_events: + remote_flags = (decide_response or {}).get("flags") or {} + request_id = (decide_response or {}).get("requestId") + evaluated_at = (decide_response or {}).get("evaluatedAt") + flag_values = response.get("featureFlags") or {} + flag_payloads = response.get("featureFlagPayloads") or {} + for key, value in flag_values.items(): + flag_was_locally_evaluated = key in locally_evaluated_keys + flag_details = ( + None if flag_was_locally_evaluated else remote_flags.get(key) + ) + payload = flag_payloads.get(key) + self._capture_feature_flag_called( + distinct_id, + key, + value, + payload, + flag_was_locally_evaluated, + groups or {}, + disable_geoip, + None if flag_was_locally_evaluated else request_id, + None if flag_was_locally_evaluated else evaluated_at, + flag_details, + ) + return response def _get_all_flags_and_payloads_locally( diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 2e7168ed..012c8680 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -7384,3 +7384,280 @@ def test_get_all_flags_locally_with_flag_keys_to_evaluate(self): # Should only return flag1 and flag3 self.assertEqual(result, {"flag1": True, "flag3": True}) self.assertNotIn("flag2", result) + + +class TestGetFeatureFlagsBulk(unittest.TestCase): + """Tests for the bulk `get_feature_flags` method and the `send_feature_flag_events` + option on `get_all_flags` / `get_all_flags_and_payloads`. + """ + + def _remote_flags_response(self): + return { + "flags": { + "flag-one": { + "key": "flag-one", + "enabled": True, + "variant": "variant-a", + "reason": { + "code": "matched", + "condition_index": 0, + "description": "Matched condition set 1", + }, + "metadata": { + "id": 11, + "version": 3, + "payload": '{"feature": "a"}', + }, + }, + "flag-two": { + "key": "flag-two", + "enabled": False, + "variant": None, + "reason": { + "code": "no_match", + "description": "Did not match any condition", + }, + "metadata": {"id": 22, "version": 7}, + }, + }, + "requestId": "req-bulk-1", + "evaluatedAt": 1700000000000, + } + + @mock.patch("posthog.client.flags") + def test_returns_feature_flag_result_per_key_from_single_remote_call( + self, patch_flags + ): + patch_flags.return_value = self._remote_flags_response() + client = Client(FAKE_TEST_API_KEY) + + results = client.get_feature_flags( + ["flag-one", "flag-two"], "user-1", send_feature_flag_events=False + ) + + self.assertEqual(results["flag-one"].key, "flag-one") + self.assertTrue(results["flag-one"].enabled) + self.assertEqual(results["flag-one"].variant, "variant-a") + self.assertEqual(results["flag-one"].payload, {"feature": "a"}) + + self.assertEqual(results["flag-two"].key, "flag-two") + self.assertFalse(results["flag-two"].enabled) + self.assertIsNone(results["flag-two"].variant) + + self.assertEqual(patch_flags.call_count, 1) + + @mock.patch.object(Client, "capture") + @mock.patch("posthog.client.flags") + def test_emits_feature_flag_called_per_resolved_flag_with_metadata( + self, patch_flags, patch_capture + ): + patch_flags.return_value = self._remote_flags_response() + client = Client(FAKE_TEST_API_KEY) + + client.get_feature_flags(["flag-one", "flag-two"], "user-1") + + self.assertEqual(patch_capture.call_count, 2) + calls_by_key = { + call.kwargs["properties"]["$feature_flag"]: call.kwargs["properties"] + for call in patch_capture.call_args_list + } + self.assertEqual( + calls_by_key["flag-one"], + { + "$feature_flag": "flag-one", + "$feature_flag_response": "variant-a", + "locally_evaluated": False, + "$feature/flag-one": "variant-a", + "$feature_flag_reason": "Matched condition set 1", + "$feature_flag_id": 11, + "$feature_flag_version": 3, + "$feature_flag_request_id": "req-bulk-1", + "$feature_flag_evaluated_at": 1700000000000, + "$feature_flag_payload": {"feature": "a"}, + }, + ) + self.assertEqual( + calls_by_key["flag-two"], + { + "$feature_flag": "flag-two", + "$feature_flag_response": False, + "locally_evaluated": False, + "$feature/flag-two": False, + "$feature_flag_reason": "Did not match any condition", + "$feature_flag_id": 22, + "$feature_flag_version": 7, + "$feature_flag_request_id": "req-bulk-1", + "$feature_flag_evaluated_at": 1700000000000, + }, + ) + + @mock.patch.object(Client, "capture") + @mock.patch("posthog.client.flags") + def test_dedupes_events_across_calls(self, patch_flags, patch_capture): + patch_flags.return_value = self._remote_flags_response() + client = Client(FAKE_TEST_API_KEY) + + client.get_feature_flags(["flag-one"], "user-1") + client.get_feature_flags(["flag-one"], "user-1") + + self.assertEqual(patch_capture.call_count, 1) + + @mock.patch.object(Client, "capture") + @mock.patch("posthog.client.flags") + def test_no_events_when_send_feature_flag_events_is_false( + self, patch_flags, patch_capture + ): + patch_flags.return_value = self._remote_flags_response() + client = Client(FAKE_TEST_API_KEY) + + client.get_feature_flags(["flag-one"], "user-1", send_feature_flag_events=False) + + self.assertEqual(patch_capture.call_count, 0) + + @mock.patch.object(Client, "capture") + @mock.patch("posthog.client.flags") + def test_emits_event_for_missing_key_with_none_response( + self, patch_flags, patch_capture + ): + patch_flags.return_value = self._remote_flags_response() + client = Client(FAKE_TEST_API_KEY) + + results = client.get_feature_flags(["flag-one", "unknown-flag"], "user-1") + + self.assertIsNone(results["unknown-flag"]) + self.assertEqual(patch_capture.call_count, 2) + calls_by_key = { + call.kwargs["properties"]["$feature_flag"]: call.kwargs["properties"] + for call in patch_capture.call_args_list + } + self.assertIn("unknown-flag", calls_by_key) + self.assertIsNone(calls_by_key["unknown-flag"]["$feature_flag_response"]) + self.assertFalse(calls_by_key["unknown-flag"]["locally_evaluated"]) + self.assertIn( + "flag_missing", calls_by_key["unknown-flag"]["$feature_flag_error"] + ) + + @mock.patch.object(Client, "capture") + @mock.patch("posthog.client.flags") + def test_only_evaluate_locally_skips_network_and_still_emits_events( + self, patch_flags, patch_capture + ): + client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) + client.feature_flags = [] + client.feature_flags_by_key = {} + + results = client.get_feature_flags( + ["missing-flag"], "user-1", only_evaluate_locally=True + ) + + self.assertIsNone(results["missing-flag"]) + self.assertEqual(patch_flags.call_count, 0) + self.assertEqual(patch_capture.call_count, 1) + props = patch_capture.call_args_list[0].kwargs["properties"] + self.assertEqual(props["$feature_flag"], "missing-flag") + self.assertIsNone(props["$feature_flag_response"]) + self.assertFalse(props["locally_evaluated"]) + + @mock.patch.object(Client, "capture") + @mock.patch("posthog.client.flags") + def test_hybrid_resolves_some_locally_and_falls_back_for_rest( + self, patch_flags, patch_capture + ): + patch_flags.return_value = { + "flags": { + "remote-flag": { + "key": "remote-flag", + "enabled": True, + "variant": None, + "reason": {"description": "Matched remotely"}, + "metadata": {"id": 99, "version": 1}, + }, + }, + "requestId": "req-hybrid", + "evaluatedAt": 1700000000001, + } + client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) + client.feature_flags = [ + { + "id": 42, + "name": "Local Flag", + "key": "local-flag", + "active": True, + "filters": {"groups": [{"properties": [], "rollout_percentage": 100}]}, + } + ] + client.feature_flags_by_key = { + flag["key"]: flag for flag in client.feature_flags + } + + results = client.get_feature_flags(["local-flag", "remote-flag"], "user-1") + + self.assertTrue(results["local-flag"].enabled) + self.assertTrue(results["remote-flag"].enabled) + + # Exactly one remote /flags call and it requested only the unresolved key. + self.assertEqual(patch_flags.call_count, 1) + self.assertEqual( + patch_flags.call_args.kwargs["flag_keys_to_evaluate"], ["remote-flag"] + ) + + self.assertEqual(patch_capture.call_count, 2) + calls_by_key = { + call.kwargs["properties"]["$feature_flag"]: call.kwargs["properties"] + for call in patch_capture.call_args_list + } + self.assertTrue(calls_by_key["local-flag"]["locally_evaluated"]) + self.assertFalse(calls_by_key["remote-flag"]["locally_evaluated"]) + self.assertEqual( + calls_by_key["remote-flag"]["$feature_flag_request_id"], "req-hybrid" + ) + + @mock.patch.object(Client, "capture") + @mock.patch("posthog.client.flags") + def test_get_all_flags_emits_events_when_send_feature_flag_events_enabled( + self, patch_flags, patch_capture + ): + patch_flags.return_value = { + "flags": { + "bulk-a": { + "key": "bulk-a", + "enabled": True, + "variant": None, + "reason": {"description": "Matched"}, + "metadata": {"id": 1, "version": 1}, + }, + "bulk-b": { + "key": "bulk-b", + "enabled": True, + "variant": "b-variant", + "reason": {"description": "Matched"}, + "metadata": {"id": 2, "version": 1}, + }, + }, + "requestId": "req-all", + } + client = Client(FAKE_TEST_API_KEY) + + result = client.get_all_flags("user-1", send_feature_flag_events=True) + + self.assertEqual(result, {"bulk-a": True, "bulk-b": "b-variant"}) + self.assertEqual(patch_capture.call_count, 2) + emitted_keys = sorted( + call.kwargs["properties"]["$feature_flag"] + for call in patch_capture.call_args_list + ) + self.assertEqual(emitted_keys, ["bulk-a", "bulk-b"]) + + @mock.patch.object(Client, "capture") + @mock.patch("posthog.client.flags") + def test_get_all_flags_default_does_not_emit_events( + self, patch_flags, patch_capture + ): + patch_flags.return_value = { + "featureFlags": {"bulk-a": True, "bulk-b": "b-variant"}, + } + client = Client(FAKE_TEST_API_KEY) + + client.get_all_flags("user-1") + + self.assertEqual(patch_capture.call_count, 0)