diff --git a/CHANGELOG.md b/CHANGELOG.md index 6044ef4..18f6245 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ -# 6.2.1 - 2025-06-21 +# 6.3.0 - 2025-07-22 + +- feat: Enhanced `send_feature_flags` parameter to accept `SendFeatureFlagsOptions` object for declarative control over local/remote evaluation and custom properties + +# 6.2.1 - 2025-07-21 - feat: make `posthog_client` an optional argument in PostHog AI providers wrappers (`posthog.ai.*`), intuitively using the default client as the default diff --git a/posthog/args.py b/posthog/args.py index 215f194..cac3642 100644 --- a/posthog/args.py +++ b/posthog/args.py @@ -5,6 +5,8 @@ import numbers from uuid import UUID +from posthog.types import SendFeatureFlagsOptions + ID_TYPES = Union[numbers.Number, str, UUID, int] @@ -22,7 +24,8 @@ class OptionalCaptureArgs(TypedDict): 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. - Defaults to False + Can be a boolean (True/False) or a SendFeatureFlagsOptions object for advanced configuration. + Defaults to False. disable_geoip: Whether to disable GeoIP lookup for this event. Defaults to False. """ @@ -32,8 +35,8 @@ class OptionalCaptureArgs(TypedDict): uuid: NotRequired[Optional[str]] groups: NotRequired[Optional[Dict[str, str]]] send_feature_flags: NotRequired[ - Optional[bool] - ] # Optional so we can tell if the user is intentionally overriding a client setting or not + Optional[Union[bool, SendFeatureFlagsOptions]] + ] # Updated to support both boolean and options object disable_geoip: NotRequired[ Optional[bool] ] # As above, optional so we can tell if the user is intentionally overriding a client setting or not diff --git a/posthog/client.py b/posthog/client.py index 0c63096..65715c4 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -524,11 +524,31 @@ def capture( extra_properties: dict[str, Any] = {} feature_variants: Optional[dict[str, Union[bool, str]]] = {} - if send_feature_flags: + + # Parse and normalize send_feature_flags parameter + flag_options = self._parse_send_feature_flags(send_feature_flags) + + if flag_options["should_send"]: try: - feature_variants = self.get_feature_variants( - distinct_id, groups, disable_geoip=disable_geoip - ) + if flag_options["only_evaluate_locally"] is True: + # Only use 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, + ) + else: + # Default behavior - use 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, + ) except Exception as e: self.log.exception( f"[FEATURE FLAGS] Unable to get feature variants: {e}" @@ -559,6 +579,42 @@ def capture( return self._enqueue(msg, disable_geoip) + def _parse_send_feature_flags(self, send_feature_flags) -> dict: + """ + Parse and normalize send_feature_flags parameter into a standard format. + + Args: + send_feature_flags: Either bool or SendFeatureFlagsOptions dict + + Returns: + dict: Normalized options with keys: should_send, only_evaluate_locally, + person_properties, group_properties + + Raises: + TypeError: If send_feature_flags is not bool or dict + """ + if isinstance(send_feature_flags, dict): + return { + "should_send": True, + "only_evaluate_locally": send_feature_flags.get( + "only_evaluate_locally" + ), + "person_properties": send_feature_flags.get("person_properties"), + "group_properties": send_feature_flags.get("group_properties"), + } + elif isinstance(send_feature_flags, bool): + return { + "should_send": send_feature_flags, + "only_evaluate_locally": None, + "person_properties": None, + "group_properties": None, + } + else: + raise TypeError( + f"Invalid type for send_feature_flags: {type(send_feature_flags)}. " + f"Expected bool or dict." + ) + def set(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: """ Set properties on a person profile. diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 03b4d99..d3cd3ef 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -751,6 +751,186 @@ def test_basic_capture_with_feature_flags_switched_off_doesnt_send_them( self.assertEqual(patch_flags.call_count, 0) + @mock.patch("posthog.client.flags") + def test_capture_with_send_feature_flags_options_only_evaluate_locally_true( + self, patch_flags + ): + """Test that SendFeatureFlagsOptions with only_evaluate_locally=True uses local evaluation""" + 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, + ) + + # Set up local flags + client.feature_flags = [ + { + "id": 1, + "key": "local-flag", + "active": True, + "filters": { + "groups": [ + { + "properties": [{"key": "region", "value": "US"}], + "rollout_percentage": 100, + } + ], + }, + } + ] + + send_options = { + "only_evaluate_locally": True, + "person_properties": {"region": "US"}, + } + + 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 not called (no remote evaluation) + patch_flags.assert_not_called() + + # Check the message includes the local flag + mock_post.assert_called_once() + batch_data = mock_post.call_args[1]["batch"] + msg = batch_data[0] + + self.assertEqual(msg["properties"]["$feature/local-flag"], True) + self.assertEqual(msg["properties"]["$active_feature_flags"], ["local-flag"]) + + @mock.patch("posthog.client.flags") + def test_capture_with_send_feature_flags_options_only_evaluate_locally_false( + self, patch_flags + ): + """Test that SendFeatureFlagsOptions with only_evaluate_locally=False forces remote evaluation""" + patch_flags.return_value = {"featureFlags": {"remote-flag": "remote-value"}} + + 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 = { + "only_evaluate_locally": False, + "person_properties": {"plan": "premium"}, + "group_properties": {"company": {"type": "enterprise"}}, + } + + msg_uuid = client.capture( + "test event", + distinct_id="distinct_id", + groups={"company": "acme"}, + send_feature_flags=send_options, + ) + + self.assertIsNotNone(msg_uuid) + self.assertFalse(self.failed) + + # Verify flags() was called with the correct properties + patch_flags.assert_called_once() + call_args = patch_flags.call_args[1] + self.assertEqual(call_args["person_properties"], {"plan": "premium"}) + self.assertEqual( + call_args["group_properties"], {"company": {"type": "enterprise"}} + ) + + # Check the message includes the remote flag + mock_post.assert_called_once() + batch_data = mock_post.call_args[1]["batch"] + msg = batch_data[0] + + self.assertEqual(msg["properties"]["$feature/remote-flag"], "remote-value") + + @mock.patch("posthog.client.flags") + def test_capture_with_send_feature_flags_options_default_behavior( + self, patch_flags + ): + """Test that SendFeatureFlagsOptions without only_evaluate_locally defaults to remote evaluation""" + patch_flags.return_value = {"featureFlags": {"default-flag": "default-value"}} + + 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 = { + "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 (default to remote evaluation) + patch_flags.assert_called_once() + call_args = patch_flags.call_args[1] + self.assertEqual(call_args["person_properties"], {"subscription": "pro"}) + + # Check the message includes the flag + mock_post.assert_called_once() + batch_data = mock_post.call_args[1]["batch"] + msg = batch_data[0] + + self.assertEqual( + msg["properties"]["$feature/default-flag"], "default-value" + ) + + @mock.patch("posthog.client.flags") + def test_capture_exception_with_send_feature_flags_options(self, patch_flags): + """Test that capture_exception also supports SendFeatureFlagsOptions""" + patch_flags.return_value = {"featureFlags": {"exception-flag": True}} + + 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 = { + "only_evaluate_locally": False, + "person_properties": {"user_type": "admin"}, + } + + try: + raise ValueError("Test exception") + except ValueError as e: + msg_uuid = client.capture_exception( + e, distinct_id="distinct_id", send_feature_flags=send_options + ) + + self.assertIsNotNone(msg_uuid) + self.assertFalse(self.failed) + + # Verify flags() was called with the correct properties + patch_flags.assert_called_once() + call_args = patch_flags.call_args[1] + self.assertEqual(call_args["person_properties"], {"user_type": "admin"}) + + # Check the message includes the flag + mock_post.assert_called_once() + batch_data = mock_post.call_args[1]["batch"] + msg = batch_data[0] + + self.assertEqual(msg["event"], "$exception") + self.assertEqual(msg["properties"]["$feature/exception-flag"], True) + def test_stringifies_distinct_id(self): # A large number that loses precision in node: # node -e "console.log(157963456373623802 + 1)" > 157963456373623800 @@ -1591,7 +1771,7 @@ def test_disable_geoip_default_on_decide(self, patch_flags): @mock.patch("posthog.client.Poller") @mock.patch("posthog.client.get") - def test_call_identify_fails(self, patch_get, patch_poll): + def test_call_identify_fails(self, patch_get, patch_poller): def raise_effect(): raise Exception("http exception") @@ -1993,3 +2173,76 @@ def test_get_remote_config_payload_requires_personal_api_key(self): result = client.get_remote_config_payload("test-flag") self.assertIsNone(result) + + def test_parse_send_feature_flags_method(self): + """Test the _parse_send_feature_flags helper method""" + client = Client(FAKE_TEST_API_KEY, sync_mode=True) + + # Test boolean True + result = client._parse_send_feature_flags(True) + expected = { + "should_send": True, + "only_evaluate_locally": None, + "person_properties": None, + "group_properties": None, + } + self.assertEqual(result, expected) + + # Test boolean False + result = client._parse_send_feature_flags(False) + expected = { + "should_send": False, + "only_evaluate_locally": None, + "person_properties": None, + "group_properties": None, + } + self.assertEqual(result, expected) + + # Test options dict with all fields + options = { + "only_evaluate_locally": True, + "person_properties": {"plan": "premium"}, + "group_properties": {"company": {"type": "enterprise"}}, + } + result = client._parse_send_feature_flags(options) + expected = { + "should_send": True, + "only_evaluate_locally": True, + "person_properties": {"plan": "premium"}, + "group_properties": {"company": {"type": "enterprise"}}, + } + self.assertEqual(result, expected) + + # Test options dict with partial fields + options = {"person_properties": {"user_id": "123"}} + result = client._parse_send_feature_flags(options) + expected = { + "should_send": True, + "only_evaluate_locally": None, + "person_properties": {"user_id": "123"}, + "group_properties": None, + } + self.assertEqual(result, expected) + + # Test empty dict + result = client._parse_send_feature_flags({}) + expected = { + "should_send": True, + "only_evaluate_locally": None, + "person_properties": None, + "group_properties": None, + } + self.assertEqual(result, expected) + + # Test invalid types + with self.assertRaises(TypeError) as cm: + client._parse_send_feature_flags("invalid") + self.assertIn("Invalid type for send_feature_flags", str(cm.exception)) + + with self.assertRaises(TypeError) as cm: + client._parse_send_feature_flags(123) + self.assertIn("Invalid type for send_feature_flags", str(cm.exception)) + + with self.assertRaises(TypeError) as cm: + client._parse_send_feature_flags(None) + self.assertIn("Invalid type for send_feature_flags", str(cm.exception)) diff --git a/posthog/types.py b/posthog/types.py index 97de99a..3cc505c 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -9,6 +9,24 @@ BeforeSendCallback = Callable[[dict[str, Any]], Optional[dict[str, Any]]] +class SendFeatureFlagsOptions(TypedDict, total=False): + """Options for sending feature flags with capture events. + + Args: + only_evaluate_locally: Whether to only use local evaluation for feature flags. + If True, only flags that can be evaluated locally will be included. + If False, remote evaluation via /flags API will be used when needed. + person_properties: Properties to use for feature flag evaluation specific to this event. + These properties will be merged with any existing person properties. + group_properties: Group properties to use for feature flag evaluation specific to this event. + Format: { group_type_name: { group_properties } } + """ + + only_evaluate_locally: Optional[bool] + person_properties: Optional[dict[str, Any]] + group_properties: Optional[dict[str, dict[str, Any]]] + + @dataclass(frozen=True) class FlagReason: code: str diff --git a/posthog/version.py b/posthog/version.py index 3bad59a..de4ee02 100644 --- a/posthog/version.py +++ b/posthog/version.py @@ -1,4 +1,4 @@ -VERSION = "6.2.1" +VERSION = "6.3.0" if __name__ == "__main__": print(VERSION, end="") # noqa: T201