diff --git a/CHANGELOG.md b/CHANGELOG.md index 44e84930..53ad7f0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 6.6.0 - 2025-08-15 + +- feat: Add `flag_keys_to_evaluate` parameter to optimize feature flag evaluation performance by only evaluating specified flags +- feat: Add `flag_keys_filter` option to `send_feature_flags` for selective flag evaluation in capture events + # 6.5.0 - 2025-08-08 - feat: Add `$context_tags` to an event to know which properties were included as tags diff --git a/example.py b/example.py index 19d1c868..c0c4af3b 100644 --- a/example.py +++ b/example.py @@ -112,7 +112,7 @@ # Local Evaluation -# If flag has City=Sydney, this call doesn't go to `/decide` +# If flag has City=Sydney, this call doesn't go to `/flags` print( posthog.feature_enabled( "test-flag", diff --git a/posthog/client.py b/posthog/client.py index 4cec29dd..350871fc 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -44,6 +44,7 @@ FlagsAndPayloads, FlagsResponse, FlagValue, + SendFeatureFlagsOptions, normalize_flags_response, to_flags_and_payloads, to_payloads, @@ -313,6 +314,7 @@ def get_feature_variants( person_properties=None, group_properties=None, disable_geoip=None, + flag_keys_to_evaluate: Optional[list[str]] = None, ) -> dict[str, Union[bool, str]]: """ Get feature flag variants for a user by calling decide. @@ -323,12 +325,19 @@ def get_feature_variants( person_properties: A dictionary of person properties. group_properties: A dictionary of group properties. disable_geoip: Whether to disable GeoIP for this request. + flag_keys_to_evaluate: A list of specific flag keys to evaluate. If provided, + only these flags will be evaluated, improving performance. Category: Feature Flags """ resp_data = self.get_flags_decision( - distinct_id, groups, person_properties, group_properties, disable_geoip + distinct_id, + groups, + person_properties, + group_properties, + disable_geoip, + flag_keys_to_evaluate, ) return to_values(resp_data) or {} @@ -339,6 +348,7 @@ def get_feature_payloads( person_properties=None, group_properties=None, disable_geoip=None, + flag_keys_to_evaluate: Optional[list[str]] = None, ) -> dict[str, str]: """ Get feature flag payloads for a user by calling decide. @@ -349,6 +359,8 @@ def get_feature_payloads( person_properties: A dictionary of person properties. group_properties: A dictionary of group properties. disable_geoip: Whether to disable GeoIP for this request. + flag_keys_to_evaluate: A list of specific flag keys to evaluate. If provided, + only these flags will be evaluated, improving performance. Examples: ```python @@ -359,7 +371,12 @@ def get_feature_payloads( Feature Flags """ resp_data = self.get_flags_decision( - distinct_id, groups, person_properties, group_properties, disable_geoip + distinct_id, + groups, + person_properties, + group_properties, + disable_geoip, + flag_keys_to_evaluate, ) return to_payloads(resp_data) or {} @@ -370,6 +387,7 @@ def get_feature_flags_and_payloads( person_properties=None, group_properties=None, disable_geoip=None, + flag_keys_to_evaluate: Optional[list[str]] = None, ) -> FlagsAndPayloads: """ Get feature flags and payloads for a user by calling decide. @@ -380,6 +398,8 @@ def get_feature_flags_and_payloads( person_properties: A dictionary of person properties. group_properties: A dictionary of group properties. disable_geoip: Whether to disable GeoIP for this request. + flag_keys_to_evaluate: A list of specific flag keys to evaluate. If provided, + only these flags will be evaluated, improving performance. Examples: ```python @@ -390,7 +410,12 @@ def get_feature_flags_and_payloads( Feature Flags """ resp = self.get_flags_decision( - distinct_id, groups, person_properties, group_properties, disable_geoip + distinct_id, + groups, + person_properties, + group_properties, + disable_geoip, + flag_keys_to_evaluate, ) return to_flags_and_payloads(resp) @@ -401,6 +426,7 @@ def get_flags_decision( person_properties=None, group_properties=None, disable_geoip=None, + flag_keys_to_evaluate: Optional[list[str]] = None, ) -> FlagsResponse: """ Get feature flags decision. @@ -411,6 +437,8 @@ def get_flags_decision( person_properties: A dictionary of person properties. group_properties: A dictionary of group properties. disable_geoip: Whether to disable GeoIP for this request. + flag_keys_to_evaluate: A list of specific flag keys to evaluate. If provided, + only these flags will be evaluated, improving performance. Examples: ```python @@ -441,6 +469,9 @@ def get_flags_decision( "geoip_disable": disable_geoip, } + if flag_keys_to_evaluate: + request_data["flag_keys_to_evaluate"] = flag_keys_to_evaluate + resp_data = flags( self.api_key, self.host, @@ -545,6 +576,7 @@ def capture( group_properties=flag_options["group_properties"], disable_geoip=disable_geoip, only_evaluate_locally=True, + flag_keys_to_evaluate=flag_options["flag_keys_filter"], ) else: # Default behavior - use remote evaluation @@ -554,6 +586,7 @@ def capture( 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( @@ -586,7 +619,7 @@ def capture( return self._enqueue(msg, disable_geoip) - def _parse_send_feature_flags(self, send_feature_flags) -> dict: + def _parse_send_feature_flags(self, send_feature_flags) -> SendFeatureFlagsOptions: """ Parse and normalize send_feature_flags parameter into a standard format. @@ -594,8 +627,8 @@ def _parse_send_feature_flags(self, send_feature_flags) -> dict: send_feature_flags: Either bool or SendFeatureFlagsOptions dict Returns: - dict: Normalized options with keys: should_send, only_evaluate_locally, - person_properties, group_properties + SendFeatureFlagsOptions: Normalized options with keys: should_send, only_evaluate_locally, + person_properties, group_properties, flag_keys_filter Raises: TypeError: If send_feature_flags is not bool or dict @@ -608,6 +641,7 @@ def _parse_send_feature_flags(self, send_feature_flags) -> dict: ), "person_properties": send_feature_flags.get("person_properties"), "group_properties": send_feature_flags.get("group_properties"), + "flag_keys_filter": send_feature_flags.get("flag_keys_filter"), } elif isinstance(send_feature_flags, bool): return { @@ -615,6 +649,7 @@ def _parse_send_feature_flags(self, send_feature_flags) -> dict: "only_evaluate_locally": None, "person_properties": None, "group_properties": None, + "flag_keys_filter": None, } else: raise TypeError( @@ -1184,12 +1219,12 @@ def _compute_flag_locally( self.log.warning( f"[FEATURE FLAGS] Unknown group type index {aggregation_group_type_index} for feature flag {feature_flag['key']}" ) - # failover to `/decide/` + # failover to `/flags` raise InconclusiveMatchError("Flag has unknown group type index") if group_name not in groups: # Group flags are never enabled in `groups` aren't passed in - # don't failover to `/decide/`, since response will be the same + # don't failover to `/flags`, since response will be the same if warn_on_unknown_groups: self.log.warning( f"[FEATURE FLAGS] Can't compute group feature flag: {feature_flag['key']} without group names passed in" @@ -1317,7 +1352,7 @@ def _get_feature_flag_result( ) elif not only_evaluate_locally: try: - flag_details, request_id = self._get_feature_flag_details_from_decide( + flag_details, request_id = self._get_feature_flag_details_from_server( key, distinct_id, groups, @@ -1557,7 +1592,7 @@ def get_feature_flag_payload( ) return feature_flag_result.payload if feature_flag_result else None - def _get_feature_flag_details_from_decide( + def _get_feature_flag_details_from_server( self, key: str, distinct_id: ID_TYPES, @@ -1567,10 +1602,15 @@ def _get_feature_flag_details_from_decide( disable_geoip: Optional[bool], ) -> tuple[Optional[FeatureFlag], Optional[str]]: """ - Calls /decide and returns the flag details and request id + Calls /flags and returns the flag details and request id """ resp_data = self.get_flags_decision( - distinct_id, groups, person_properties, group_properties, disable_geoip + distinct_id, + groups, + person_properties, + group_properties, + disable_geoip, + flag_keys_to_evaluate=[key], ) request_id = resp_data.get("requestId") flags = resp_data.get("flags") @@ -1686,6 +1726,7 @@ def get_all_flags( group_properties=None, only_evaluate_locally=False, disable_geoip=None, + flag_keys_to_evaluate: Optional[list[str]] = None, ) -> Optional[dict[str, Union[bool, str]]]: """ Get all feature flags for a user. @@ -1697,6 +1738,8 @@ def get_all_flags( group_properties: A dictionary of group properties. only_evaluate_locally: Whether to only evaluate locally. disable_geoip: Whether to disable GeoIP for this request. + flag_keys_to_evaluate: A list of specific flag keys to evaluate. If provided, + only these flags will be evaluated, improving performance. Examples: ```python @@ -1713,6 +1756,7 @@ def get_all_flags( group_properties=group_properties, only_evaluate_locally=only_evaluate_locally, disable_geoip=disable_geoip, + flag_keys_to_evaluate=flag_keys_to_evaluate, ) return response["featureFlags"] @@ -1726,6 +1770,7 @@ def get_all_flags_and_payloads( group_properties=None, only_evaluate_locally=False, disable_geoip=None, + flag_keys_to_evaluate: Optional[list[str]] = None, ) -> FlagsAndPayloads: """ Get all feature flags and their payloads for a user. @@ -1737,6 +1782,8 @@ def get_all_flags_and_payloads( group_properties: A dictionary of group properties. only_evaluate_locally: Whether to only evaluate locally. disable_geoip: Whether to disable GeoIP for this request. + flag_keys_to_evaluate: A list of specific flag keys to evaluate. If provided, + only these flags will be evaluated, improving performance. Examples: ```python @@ -1760,6 +1807,7 @@ def get_all_flags_and_payloads( groups=groups, person_properties=person_properties, group_properties=group_properties, + flag_keys_to_evaluate=flag_keys_to_evaluate, ) if fallback_to_decide and not only_evaluate_locally: @@ -1770,6 +1818,7 @@ def get_all_flags_and_payloads( person_properties=person_properties, group_properties=group_properties, disable_geoip=disable_geoip, + flag_keys_to_evaluate=flag_keys_to_evaluate, ) return to_flags_and_payloads(decide_response) except Exception as e: @@ -1787,6 +1836,7 @@ def _get_all_flags_and_payloads_locally( person_properties=None, group_properties=None, warn_on_unknown_groups=False, + flag_keys_to_evaluate: Optional[list[str]] = None, ) -> tuple[FlagsAndPayloads, bool]: person_properties = person_properties or {} group_properties = group_properties or {} @@ -1799,7 +1849,15 @@ def _get_all_flags_and_payloads_locally( fallback_to_decide = False # If loading in previous line failed if self.feature_flags: - for flag in self.feature_flags: + # Filter flags based on flag_keys_to_evaluate if provided + flags_to_process = self.feature_flags + if flag_keys_to_evaluate: + flag_keys_set = set(flag_keys_to_evaluate) + flags_to_process = [ + flag for flag in self.feature_flags if flag["key"] in flag_keys_set + ] + + for flag in flags_to_process: try: flags[flag["key"]] = self._compute_flag_locally( flag, @@ -1815,7 +1873,7 @@ def _get_all_flags_and_payloads_locally( if matched_payload is not None: payloads[flag["key"]] = matched_payload except InconclusiveMatchError: - # No need to log this, since it's just telling us to fall back to `/decide` + # No need to log this, since it's just telling us to fall back to `/flags` fallback_to_decide = True except Exception as e: self.log.exception( diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index af78add4..3606c26c 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -1742,6 +1742,7 @@ def test_disable_geoip_default_on_decide(self, patch_flags): person_properties={"distinct_id": "some_id"}, group_properties={}, geoip_disable=True, + flag_keys_to_evaluate=["random_key"], ) patch_flags.reset_mock() client.feature_enabled( @@ -1756,6 +1757,7 @@ def test_disable_geoip_default_on_decide(self, patch_flags): person_properties={"distinct_id": "feature_enabled_distinct_id"}, group_properties={}, geoip_disable=True, + flag_keys_to_evaluate=["random_key"], ) patch_flags.reset_mock() client.get_all_flags_and_payloads("all_flags_payloads_id") @@ -1816,6 +1818,7 @@ def test_default_properties_get_added_properly(self, patch_flags): "instance": {"$group_key": "app.posthog.com"}, }, geoip_disable=False, + flag_keys_to_evaluate=["random_key"], ) patch_flags.reset_mock() @@ -1842,6 +1845,7 @@ def test_default_properties_get_added_properly(self, patch_flags): "instance": {"$group_key": "app.posthog.com"}, }, geoip_disable=False, + flag_keys_to_evaluate=["random_key"], ) patch_flags.reset_mock() @@ -2187,6 +2191,7 @@ def test_parse_send_feature_flags_method(self): "only_evaluate_locally": None, "person_properties": None, "group_properties": None, + "flag_keys_filter": None, } self.assertEqual(result, expected) @@ -2197,6 +2202,7 @@ def test_parse_send_feature_flags_method(self): "only_evaluate_locally": None, "person_properties": None, "group_properties": None, + "flag_keys_filter": None, } self.assertEqual(result, expected) @@ -2212,6 +2218,7 @@ def test_parse_send_feature_flags_method(self): "only_evaluate_locally": True, "person_properties": {"plan": "premium"}, "group_properties": {"company": {"type": "enterprise"}}, + "flag_keys_filter": None, } self.assertEqual(result, expected) @@ -2223,6 +2230,7 @@ def test_parse_send_feature_flags_method(self): "only_evaluate_locally": None, "person_properties": {"user_id": "123"}, "group_properties": None, + "flag_keys_filter": None, } self.assertEqual(result, expected) @@ -2233,6 +2241,7 @@ def test_parse_send_feature_flags_method(self): "only_evaluate_locally": None, "person_properties": None, "group_properties": None, + "flag_keys_filter": None, } self.assertEqual(result, expected) @@ -2249,6 +2258,53 @@ def test_parse_send_feature_flags_method(self): client._parse_send_feature_flags(None) self.assertIn("Invalid type for send_feature_flags", str(cm.exception)) + @mock.patch("posthog.client.flags") + def test_capture_with_send_feature_flags_flag_keys_filter(self, patch_flags): + """Test that SendFeatureFlagsOptions with flag_keys_filter only evaluates specified flags""" + # When flag_keys_to_evaluate is provided, the API should only return the requested flags + patch_flags.return_value = { + "featureFlags": { + "flag1": "value1", + "flag3": "value3", + } + } + + with mock.patch("posthog.client.batch_post") as mock_post: + client = Client( + FAKE_TEST_API_KEY, + on_error=self.set_fail, + personal_api_key=FAKE_TEST_API_KEY, + sync_mode=True, + ) + + send_options = { + "flag_keys_filter": ["flag1", "flag3"], + "person_properties": {"subscription": "pro"}, + } + + msg_uuid = client.capture( + "test event", distinct_id="distinct_id", send_feature_flags=send_options + ) + + self.assertIsNotNone(msg_uuid) + self.assertFalse(self.failed) + + # Verify flags() was called with flag_keys_to_evaluate + patch_flags.assert_called_once() + call_args = patch_flags.call_args[1] + self.assertEqual(call_args["flag_keys_to_evaluate"], ["flag1", "flag3"]) + self.assertEqual(call_args["person_properties"], {"subscription": "pro"}) + + # Check the message includes only the filtered flags + mock_post.assert_called_once() + batch_data = mock_post.call_args[1]["batch"] + msg = batch_data[0] + + self.assertEqual(msg["properties"]["$feature/flag1"], "value1") + self.assertEqual(msg["properties"]["$feature/flag3"], "value3") + # flag2 should not be included since it wasn't requested + self.assertNotIn("$feature/flag2", msg["properties"]) + @mock.patch("posthog.client.batch_post") def test_get_feature_flag_result_with_empty_string_payload(self, patch_batch_post): """Test that get_feature_flag_result returns a FeatureFlagResult when payload is empty string""" diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 3b5c457c..a30eddf2 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -215,7 +215,7 @@ def test_flag_group_properties(self, patch_get, patch_flags): ) self.assertEqual(patch_flags.call_count, 0) - # Now group type mappings are gone, so fall back to /decide/ + # Now group type mappings are gone, so fall back to /flags/ patch_flags.return_value = { "featureFlags": {"group-flag": "decide-fallback-value"} } @@ -311,7 +311,7 @@ def test_flag_with_complex_definition(self, patch_get, patch_flags): ) self.assertEqual(patch_flags.call_count, 0) - # will fall back on `/decide`, as all properties present for second group, but that group resolves to false + # will fall back on `/flags`, as all properties present for second group, but that group resolves to false self.assertEqual( client.get_feature_flag( "complex-flag", @@ -651,7 +651,7 @@ def test_get_all_flags_with_fallback(self, patch_flags, patch_capture): }, }, ] - # beta-feature value overridden by /decide + # beta-feature value overridden by /flags self.assertEqual( client.get_all_flags("distinct_id"), { @@ -725,7 +725,7 @@ def test_get_all_flags_and_payloads_with_fallback(self, patch_flags, patch_captu }, }, ] - # beta-feature value overridden by /decide + # beta-feature value overridden by /flags self.assertEqual( client.get_all_flags_and_payloads("distinct_id")["featureFlagPayloads"], { @@ -746,7 +746,7 @@ def test_get_all_flags_with_fallback_empty_local_flags( } client = self.client client.feature_flags = [] - # beta-feature value overridden by /decide + # beta-feature value overridden by /flags self.assertEqual( client.get_all_flags("distinct_id"), {"beta-feature": "variant-1", "beta-feature2": "variant-2"}, @@ -765,7 +765,7 @@ def test_get_all_flags_and_payloads_with_fallback_empty_local_flags( } client = self.client client.feature_flags = [] - # beta-feature value overridden by /decide + # beta-feature value overridden by /flags self.assertEqual( client.get_all_flags_and_payloads("distinct_id")["featureFlagPayloads"], {"beta-feature": 100, "beta-feature2": 300}, @@ -5387,4 +5387,115 @@ def test_feature_flag_case_sensitive_consistency(self, mock_decide): test_cases = ["beta-feature", "BETA-FEATURE", "bEtA-FeAtUrE"] for case in test_cases: self.assertFalse(client.feature_enabled(case, "user1")) - self.assertIsNone(client.get_feature_flag_payload(case, "user1")) + + @mock.patch("posthog.client.flags") + def test_get_all_flags_with_flag_keys_to_evaluate(self, mock_flags): + """Test that get_all_flags with flag_keys_to_evaluate only evaluates specified flags""" + mock_flags.return_value = { + "featureFlags": { + "flag1": "value1", + "flag2": True, + } + } + + client = Client( + project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY + ) + + # Call get_all_flags with flag_keys_to_evaluate + result = client.get_all_flags( + "user123", + flag_keys_to_evaluate=["flag1", "flag2"], + person_properties={"region": "USA"}, + ) + + # Verify flags() was called with flag_keys_to_evaluate + mock_flags.assert_called_once() + call_args = mock_flags.call_args[1] + self.assertEqual(call_args["flag_keys_to_evaluate"], ["flag1", "flag2"]) + self.assertEqual( + call_args["person_properties"], {"distinct_id": "user123", "region": "USA"} + ) + + # Check the result + self.assertEqual(result, {"flag1": "value1", "flag2": True}) + + @mock.patch("posthog.client.flags") + def test_get_all_flags_and_payloads_with_flag_keys_to_evaluate(self, mock_flags): + """Test that get_all_flags_and_payloads with flag_keys_to_evaluate only evaluates specified flags""" + mock_flags.return_value = { + "featureFlags": { + "flag1": "variant1", + "flag3": True, + }, + "featureFlagPayloads": { + "flag1": {"data": "payload1"}, + "flag3": {"data": "payload3"}, + }, + } + + client = Client( + project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY + ) + + # Call get_all_flags_and_payloads with flag_keys_to_evaluate + result = client.get_all_flags_and_payloads( + "user123", + flag_keys_to_evaluate=["flag1", "flag3"], + person_properties={"subscription": "pro"}, + ) + + # Verify flags() was called with flag_keys_to_evaluate + mock_flags.assert_called_once() + call_args = mock_flags.call_args[1] + self.assertEqual(call_args["flag_keys_to_evaluate"], ["flag1", "flag3"]) + self.assertEqual( + call_args["person_properties"], + {"distinct_id": "user123", "subscription": "pro"}, + ) + + # Check the result + self.assertEqual(result["featureFlags"], {"flag1": "variant1", "flag3": True}) + self.assertEqual( + result["featureFlagPayloads"], + {"flag1": {"data": "payload1"}, "flag3": {"data": "payload3"}}, + ) + + def test_get_all_flags_locally_with_flag_keys_to_evaluate(self): + """Test that local evaluation with flag_keys_to_evaluate only evaluates specified flags""" + client = Client( + project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY + ) + + # Set up multiple flags + client.feature_flags = [ + { + "id": 1, + "key": "flag1", + "active": True, + "filters": {"groups": [{"properties": [], "rollout_percentage": 100}]}, + }, + { + "id": 2, + "key": "flag2", + "active": True, + "filters": {"groups": [{"properties": [], "rollout_percentage": 100}]}, + }, + { + "id": 3, + "key": "flag3", + "active": True, + "filters": {"groups": [{"properties": [], "rollout_percentage": 100}]}, + }, + ] + + # Call get_all_flags with flag_keys_to_evaluate + result = client.get_all_flags( + "user123", + flag_keys_to_evaluate=["flag1", "flag3"], + only_evaluate_locally=True, + ) + + # Should only return flag1 and flag3 + self.assertEqual(result, {"flag1": True, "flag3": True}) + self.assertNotIn("flag2", result) diff --git a/posthog/types.py b/posthog/types.py index d27eb2a0..951844e2 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -9,6 +9,7 @@ BeforeSendCallback = Callable[[dict[str, Any]], Optional[dict[str, Any]]] +# Type alias for the send_feature_flags parameter class SendFeatureFlagsOptions(TypedDict, total=False): """Options for sending feature flags with capture events. @@ -22,9 +23,11 @@ class SendFeatureFlagsOptions(TypedDict, total=False): Format: { group_type_name: { group_properties } } """ + should_send: bool only_evaluate_locally: Optional[bool] person_properties: Optional[dict[str, Any]] group_properties: Optional[dict[str, dict[str, Any]]] + flag_keys_filter: Optional[list[str]] @dataclass(frozen=True) diff --git a/posthog/version.py b/posthog/version.py index 04894d84..137c6564 100644 --- a/posthog/version.py +++ b/posthog/version.py @@ -1,4 +1,4 @@ -VERSION = "6.5.0" +VERSION = "6.6.0" if __name__ == "__main__": print(VERSION, end="") # noqa: T201