diff --git a/CHANGELOG.md b/CHANGELOG.md index 66849da2..21b8cc37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 7.6.0 - 2026-01-12 + +feat: add device_id to flags request payload + +Add device_id parameter to all feature flag methods, allowing the server to track device identifiers for flag evaluation. The device_id can be passed explicitly or set via context using `set_context_device_id()`. + # 7.5.1 - 2026-01-07 fix: avoid return from finally block to fix Python 3.14 SyntaxWarning (#361) - thanks @jodal diff --git a/posthog/__init__.py b/posthog/__init__.py index 8dbed815..ff0ce635 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -23,6 +23,9 @@ from posthog.contexts import ( set_code_variables_mask_patterns_context as inner_set_code_variables_mask_patterns_context, ) +from posthog.contexts import ( + set_context_device_id as inner_set_context_device_id, +) from posthog.contexts import ( set_context_session as inner_set_context_session, ) @@ -133,6 +136,26 @@ def set_context_session(session_id: str): return inner_set_context_session(session_id) +def set_context_device_id(device_id: str): + """ + Set the device ID for the current context, associating all feature flag requests + in this or child contexts with the given device ID. + + Args: + device_id: The device ID to associate with the current context and its children + + Examples: + ```python + from posthog import set_context_device_id + set_context_device_id("device_123") + ``` + + Category: + Contexts + """ + return inner_set_context_device_id(device_id) + + def identify_context(distinct_id: str): """ Identify the current context with a distinct ID. @@ -483,6 +506,7 @@ def feature_enabled( only_evaluate_locally=False, # type: bool send_feature_flag_events=True, # type: bool disable_geoip=None, # type: Optional[bool] + device_id=None, # type: Optional[str] ): # type: (...) -> bool """ @@ -522,6 +546,7 @@ def feature_enabled( only_evaluate_locally=only_evaluate_locally, send_feature_flag_events=send_feature_flag_events, disable_geoip=disable_geoip, + device_id=device_id, ) @@ -534,6 +559,7 @@ def get_feature_flag( only_evaluate_locally=False, # type: bool send_feature_flag_events=True, # type: bool disable_geoip=None, # type: Optional[bool] + device_id=None, # type: Optional[str] ) -> Optional[FeatureFlag]: """ Get feature flag variant for users. Used with experiments. @@ -572,6 +598,7 @@ def get_feature_flag( only_evaluate_locally=only_evaluate_locally, send_feature_flag_events=send_feature_flag_events, disable_geoip=disable_geoip, + device_id=device_id, ) @@ -582,6 +609,7 @@ def get_all_flags( group_properties=None, # type: Optional[dict] only_evaluate_locally=False, # type: bool disable_geoip=None, # type: Optional[bool] + device_id=None, # type: Optional[str] ) -> Optional[dict[str, FeatureFlag]]: """ Get all flags for a given user. @@ -614,6 +642,7 @@ def get_all_flags( group_properties=group_properties or {}, only_evaluate_locally=only_evaluate_locally, disable_geoip=disable_geoip, + device_id=device_id, ) @@ -626,6 +655,7 @@ def get_feature_flag_result( only_evaluate_locally=False, send_feature_flag_events=True, disable_geoip=None, # type: Optional[bool] + device_id=None, # type: Optional[str] ): # type: (...) -> Optional[FeatureFlagResult] """ @@ -657,6 +687,7 @@ def get_feature_flag_result( only_evaluate_locally=only_evaluate_locally, send_feature_flag_events=send_feature_flag_events, disable_geoip=disable_geoip, + device_id=device_id, ) @@ -670,6 +701,7 @@ def get_feature_flag_payload( only_evaluate_locally=False, send_feature_flag_events=True, disable_geoip=None, # type: Optional[bool] + device_id=None, # type: Optional[str] ) -> Optional[str]: return _proxy( "get_feature_flag_payload", @@ -682,6 +714,7 @@ def get_feature_flag_payload( only_evaluate_locally=only_evaluate_locally, send_feature_flag_events=send_feature_flag_events, disable_geoip=disable_geoip, + device_id=device_id, ) @@ -712,6 +745,7 @@ def get_all_flags_and_payloads( group_properties=None, # type: Optional[dict] only_evaluate_locally=False, disable_geoip=None, # type: Optional[bool] + device_id=None, # type: Optional[str] ) -> FlagsAndPayloads: return _proxy( "get_all_flags_and_payloads", @@ -721,6 +755,7 @@ def get_all_flags_and_payloads( group_properties=group_properties or {}, only_evaluate_locally=only_evaluate_locally, disable_geoip=disable_geoip, + device_id=device_id, ) diff --git a/posthog/client.py b/posthog/client.py index cf6065fe..6470fd3e 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -18,6 +18,7 @@ get_capture_exception_code_variables_context, get_code_variables_ignore_patterns_context, get_code_variables_mask_patterns_context, + get_context_device_id, get_context_distinct_id, get_context_session_id, new_context, @@ -382,6 +383,7 @@ def get_feature_variants( group_properties=None, disable_geoip=None, flag_keys_to_evaluate: Optional[list[str]] = None, + device_id: Optional[str] = None, ) -> dict[str, Union[bool, str]]: """ Get feature flag variants for a user by calling decide. @@ -394,6 +396,7 @@ def get_feature_variants( 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. + device_id: The device ID for this request. Category: Feature flags @@ -405,6 +408,7 @@ def get_feature_variants( group_properties, disable_geoip, flag_keys_to_evaluate, + device_id=device_id, ) return to_values(resp_data) or {} @@ -416,6 +420,7 @@ def get_feature_payloads( group_properties=None, disable_geoip=None, flag_keys_to_evaluate: Optional[list[str]] = None, + device_id: Optional[str] = None, ) -> dict[str, str]: """ Get feature flag payloads for a user by calling decide. @@ -428,6 +433,7 @@ def get_feature_payloads( 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. + device_id: The device ID for this request. Examples: ```python @@ -444,6 +450,7 @@ def get_feature_payloads( group_properties, disable_geoip, flag_keys_to_evaluate, + device_id=device_id, ) return to_payloads(resp_data) or {} @@ -455,6 +462,7 @@ def get_feature_flags_and_payloads( group_properties=None, disable_geoip=None, flag_keys_to_evaluate: Optional[list[str]] = None, + device_id: Optional[str] = None, ) -> FlagsAndPayloads: """ Get feature flags and payloads for a user by calling decide. @@ -467,6 +475,7 @@ def get_feature_flags_and_payloads( 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. + device_id: The device ID for this request. Examples: ```python @@ -483,6 +492,7 @@ def get_feature_flags_and_payloads( group_properties, disable_geoip, flag_keys_to_evaluate, + device_id=device_id, ) return to_flags_and_payloads(resp) @@ -494,6 +504,7 @@ def get_flags_decision( group_properties=None, disable_geoip=None, flag_keys_to_evaluate: Optional[list[str]] = None, + device_id: Optional[str] = None, ) -> FlagsResponse: """ Get feature flags decision. @@ -506,6 +517,7 @@ def get_flags_decision( 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. + device_id: The device ID for this request. Examples: ```python @@ -522,6 +534,9 @@ def get_flags_decision( if distinct_id is None: distinct_id = get_context_distinct_id() + if device_id is None: + device_id = get_context_device_id() + if disable_geoip is None: disable_geoip = self.disable_geoip @@ -534,6 +549,7 @@ def get_flags_decision( "person_properties": person_properties, "group_properties": group_properties, "geoip_disable": disable_geoip, + "device_id": device_id, } if flag_keys_to_evaluate: @@ -1464,6 +1480,7 @@ def feature_enabled( only_evaluate_locally=False, send_feature_flag_events=True, disable_geoip=None, + device_id: Optional[str] = None, ): """ Check if a feature flag is enabled for a user. @@ -1477,6 +1494,7 @@ def feature_enabled( only_evaluate_locally: Whether to only evaluate locally. send_feature_flag_events: Whether to send feature flag events. disable_geoip: Whether to disable GeoIP for this request. + device_id: The device ID for this request. Examples: ```python @@ -1499,6 +1517,7 @@ def feature_enabled( only_evaluate_locally=only_evaluate_locally, send_feature_flag_events=send_feature_flag_events, disable_geoip=disable_geoip, + device_id=device_id, ) if response is None: @@ -1530,6 +1549,7 @@ def _get_feature_flag_result( only_evaluate_locally=False, send_feature_flag_events=True, disable_geoip=None, + device_id: Optional[str] = None, ) -> Optional[FeatureFlagResult]: if self.disabled: return None @@ -1584,6 +1604,7 @@ def _get_feature_flag_result( person_properties, group_properties, disable_geoip, + device_id=device_id, ) ) errors = [] @@ -1656,6 +1677,7 @@ def get_feature_flag_result( only_evaluate_locally=False, send_feature_flag_events=True, disable_geoip=None, + device_id: Optional[str] = None, ) -> Optional[FeatureFlagResult]: """ Get a FeatureFlagResult object which contains the flag result and payload for a key by evaluating locally or remotely @@ -1680,6 +1702,7 @@ def get_feature_flag_result( only_evaluate_locally: Whether to only evaluate locally. send_feature_flag_events: Whether to send feature flag events. disable_geoip: Whether to disable GeoIP for this request. + device_id: The device ID for this request. Returns: Optional[FeatureFlagResult]: The feature flag result or None if disabled/not found. @@ -1693,6 +1716,7 @@ def get_feature_flag_result( only_evaluate_locally=only_evaluate_locally, send_feature_flag_events=send_feature_flag_events, disable_geoip=disable_geoip, + device_id=device_id, ) def get_feature_flag( @@ -1706,6 +1730,7 @@ def get_feature_flag( only_evaluate_locally=False, send_feature_flag_events=True, disable_geoip=None, + device_id: Optional[str] = None, ) -> Optional[FlagValue]: """ Get multivariate feature flag value for a user. @@ -1719,6 +1744,7 @@ def get_feature_flag( only_evaluate_locally: Whether to only evaluate locally. send_feature_flag_events: Whether to send feature flag events. disable_geoip: Whether to disable GeoIP for this request. + device_id: The device ID for this request. Examples: ```python @@ -1741,6 +1767,7 @@ def get_feature_flag( only_evaluate_locally=only_evaluate_locally, send_feature_flag_events=send_feature_flag_events, disable_geoip=disable_geoip, + device_id=device_id, ) return feature_flag_result.get_value() if feature_flag_result else None @@ -1794,6 +1821,7 @@ def get_feature_flag_payload( only_evaluate_locally=False, send_feature_flag_events=False, disable_geoip=None, + device_id: Optional[str] = None, ): """ Get the payload for a feature flag. @@ -1808,6 +1836,7 @@ def get_feature_flag_payload( only_evaluate_locally: Whether to only evaluate locally. send_feature_flag_events: Deprecated. Use get_feature_flag() instead if you need events. disable_geoip: Whether to disable GeoIP for this request. + device_id: The device ID for this request. Examples: ```python @@ -1840,6 +1869,7 @@ def get_feature_flag_payload( only_evaluate_locally=only_evaluate_locally, send_feature_flag_events=send_feature_flag_events, disable_geoip=disable_geoip, + device_id=device_id, ) return feature_flag_result.payload if feature_flag_result else None @@ -1851,6 +1881,7 @@ def _get_feature_flag_details_from_server( person_properties: dict[str, str], group_properties: dict[str, str], disable_geoip: Optional[bool], + device_id: Optional[str] = None, ) -> tuple[Optional[FeatureFlag], Optional[str], Optional[int], bool]: """ Calls /flags and returns the flag details, request id, evaluated at timestamp, @@ -1863,6 +1894,7 @@ def _get_feature_flag_details_from_server( group_properties, disable_geoip, flag_keys_to_evaluate=[key], + device_id=device_id, ) request_id = resp_data.get("requestId") evaluated_at = resp_data.get("evaluatedAt") @@ -1987,6 +2019,7 @@ def get_all_flags( only_evaluate_locally=False, disable_geoip=None, flag_keys_to_evaluate: Optional[list[str]] = None, + device_id: Optional[str] = None, ) -> Optional[dict[str, Union[bool, str]]]: """ Get all feature flags for a user. @@ -2000,6 +2033,7 @@ def get_all_flags( 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. + device_id: The device ID for this request. Examples: ```python @@ -2017,6 +2051,7 @@ def get_all_flags( only_evaluate_locally=only_evaluate_locally, disable_geoip=disable_geoip, flag_keys_to_evaluate=flag_keys_to_evaluate, + device_id=device_id, ) return response["featureFlags"] @@ -2031,6 +2066,7 @@ def get_all_flags_and_payloads( only_evaluate_locally=False, disable_geoip=None, flag_keys_to_evaluate: Optional[list[str]] = None, + device_id: Optional[str] = None, ) -> FlagsAndPayloads: """ Get all feature flags and their payloads for a user. @@ -2044,6 +2080,7 @@ def get_all_flags_and_payloads( 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. + device_id: The device ID for this request. Examples: ```python @@ -2079,6 +2116,7 @@ def get_all_flags_and_payloads( group_properties=group_properties, disable_geoip=disable_geoip, flag_keys_to_evaluate=flag_keys_to_evaluate, + device_id=device_id, ) return to_flags_and_payloads(decide_response) except Exception as e: diff --git a/posthog/contexts.py b/posthog/contexts.py index 39f9bfde..326916da 100644 --- a/posthog/contexts.py +++ b/posthog/contexts.py @@ -21,6 +21,7 @@ def __init__( self.capture_exceptions = capture_exceptions self.session_id: Optional[str] = None self.distinct_id: Optional[str] = None + self.device_id: Optional[str] = None self.tags: Dict[str, Any] = {} self.capture_exception_code_variables: Optional[bool] = None self.code_variables_mask_patterns: Optional[list] = None @@ -32,6 +33,9 @@ def set_session_id(self, session_id: str): def set_distinct_id(self, distinct_id: str): self.distinct_id = distinct_id + def set_device_id(self, device_id: str): + self.device_id = device_id + def add_tag(self, key: str, value: Any): self.tags[key] = value @@ -61,6 +65,13 @@ def get_distinct_id(self) -> Optional[str]: return self.parent.get_distinct_id() return None + def get_device_id(self) -> Optional[str]: + if self.device_id is not None: + return self.device_id + if self.parent is not None and not self.fresh: + return self.parent.get_device_id() + return None + def collect_tags(self) -> Dict[str, Any]: if self.parent and not self.fresh: # We want child tags to take precedence over parent tags, @@ -275,6 +286,39 @@ def get_context_distinct_id() -> Optional[str]: return None +def set_context_device_id(device_id: str) -> None: + """ + Set the device ID for the current context, associating all feature flag requests in this or + child contexts with the given device ID (unless set_context_device_id is called again). + Entering a fresh context will clear the context-level device ID. + + Args: + device_id: The device ID to associate with the current context and its children. + + Category: + Contexts + """ + current_context = _get_current_context() + if current_context: + current_context.set_device_id(device_id) + + +def get_context_device_id() -> Optional[str]: + """ + Get the device ID for the current context. + + Returns: + The device ID if set, None otherwise + + Category: + Contexts + """ + current_context = _get_current_context() + if current_context: + return current_context.get_device_id() + return None + + def set_capture_exception_code_variables_context(enabled: bool) -> None: """ Set whether code variables are captured for the current context. diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index a866b90b..1bd55267 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -648,6 +648,7 @@ def test_basic_capture_with_feature_flags_returns_active_only(self, patch_flags) person_properties={}, group_properties={}, geoip_disable=True, + device_id=None, ) @mock.patch("posthog.client.flags") @@ -712,6 +713,7 @@ def test_basic_capture_with_feature_flags_and_disable_geoip_returns_correctly( person_properties={}, group_properties={}, geoip_disable=False, + device_id=None, ) @mock.patch("posthog.client.flags") @@ -1911,6 +1913,7 @@ def test_disable_geoip_default_on_decide(self, patch_flags): person_properties={"distinct_id": "some_id"}, group_properties={}, geoip_disable=True, + device_id=None, flag_keys_to_evaluate=["random_key"], ) patch_flags.reset_mock() @@ -1926,6 +1929,7 @@ def test_disable_geoip_default_on_decide(self, patch_flags): person_properties={"distinct_id": "feature_enabled_distinct_id"}, group_properties={}, geoip_disable=True, + device_id=None, flag_keys_to_evaluate=["random_key"], ) patch_flags.reset_mock() @@ -1939,6 +1943,7 @@ def test_disable_geoip_default_on_decide(self, patch_flags): person_properties={"distinct_id": "all_flags_payloads_id"}, group_properties={}, geoip_disable=False, + device_id=None, ) @mock.patch("posthog.client.Poller") @@ -1987,6 +1992,7 @@ def test_default_properties_get_added_properly(self, patch_flags): "instance": {"$group_key": "app.posthog.com"}, }, geoip_disable=False, + device_id=None, flag_keys_to_evaluate=["random_key"], ) @@ -2014,6 +2020,7 @@ def test_default_properties_get_added_properly(self, patch_flags): "instance": {"$group_key": "app.posthog.com"}, }, geoip_disable=False, + device_id=None, flag_keys_to_evaluate=["random_key"], ) @@ -2031,8 +2038,135 @@ def test_default_properties_get_added_properly(self, patch_flags): person_properties={"distinct_id": "some_id"}, group_properties={}, geoip_disable=False, + device_id=None, ) + @mock.patch("posthog.client.flags") + def test_device_id_is_passed_to_flags_request(self, patch_flags): + """Test that device_id is properly passed to the flags request when provided.""" + patch_flags.return_value = { + "featureFlags": { + "beta-feature": "random-variant", + } + } + client = Client( + FAKE_TEST_API_KEY, + on_error=self.set_fail, + ) + + # Test with device_id provided + client.get_feature_flag("random_key", "some_id", device_id="test-device-123") + patch_flags.assert_called_with( + "random_key", + "https://us.i.posthog.com", + timeout=3, + distinct_id="some_id", + groups={}, + person_properties={"distinct_id": "some_id"}, + group_properties={}, + geoip_disable=True, + device_id="test-device-123", + flag_keys_to_evaluate=["random_key"], + ) + + # Test feature_enabled with device_id + patch_flags.reset_mock() + client.feature_enabled("random_key", "some_id", device_id="device-456") + patch_flags.assert_called_with( + "random_key", + "https://us.i.posthog.com", + timeout=3, + distinct_id="some_id", + groups={}, + person_properties={"distinct_id": "some_id"}, + group_properties={}, + geoip_disable=True, + device_id="device-456", + flag_keys_to_evaluate=["random_key"], + ) + + # Test get_all_flags_and_payloads with device_id + patch_flags.reset_mock() + client.get_all_flags_and_payloads("some_id", device_id="device-789") + patch_flags.assert_called_with( + "random_key", + "https://us.i.posthog.com", + timeout=3, + distinct_id="some_id", + groups={}, + person_properties={"distinct_id": "some_id"}, + group_properties={}, + geoip_disable=True, + device_id="device-789", + ) + + # Test get_flags_decision directly with device_id + patch_flags.reset_mock() + client.get_flags_decision("some_id", device_id="device-direct") + patch_flags.assert_called_with( + "random_key", + "https://us.i.posthog.com", + timeout=3, + distinct_id="some_id", + groups={}, + person_properties={}, + group_properties={}, + geoip_disable=True, + device_id="device-direct", + ) + + @mock.patch("posthog.client.flags") + def test_device_id_from_context_is_used_in_flags_request(self, patch_flags): + """Test that device_id from context is used in flags request when not explicitly provided.""" + from posthog.contexts import new_context, set_context_device_id + + patch_flags.return_value = { + "featureFlags": { + "beta-feature": "random-variant", + } + } + client = Client( + FAKE_TEST_API_KEY, + on_error=self.set_fail, + ) + + # Test that device_id from context is used + with new_context(): + set_context_device_id("context-device-id") + client.get_feature_flag("random_key", "some_id") + patch_flags.assert_called_with( + "random_key", + "https://us.i.posthog.com", + timeout=3, + distinct_id="some_id", + groups={}, + person_properties={"distinct_id": "some_id"}, + group_properties={}, + geoip_disable=True, + device_id="context-device-id", + flag_keys_to_evaluate=["random_key"], + ) + + # Test that explicit device_id overrides context + patch_flags.reset_mock() + with new_context(): + set_context_device_id("context-device-id") + client.get_feature_flag( + "random_key", "some_id", device_id="explicit-device-id" + ) + patch_flags.assert_called_with( + "random_key", + "https://us.i.posthog.com", + timeout=3, + distinct_id="some_id", + groups={}, + person_properties={"distinct_id": "some_id"}, + group_properties={}, + geoip_disable=True, + device_id="explicit-device-id", + flag_keys_to_evaluate=["random_key"], + ) + @parameterized.expand( [ # name, sys_platform, version_info, expected_runtime, expected_version, expected_os, expected_os_version, platform_method, platform_return, distro_info diff --git a/posthog/version.py b/posthog/version.py index 353acdce..05114bf5 100644 --- a/posthog/version.py +++ b/posthog/version.py @@ -1,4 +1,4 @@ -VERSION = "7.5.1" +VERSION = "7.6.0" if __name__ == "__main__": print(VERSION, end="") # noqa: T201