From 03d2ff843e5fe2702e346d818661b6f623806071 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Thu, 5 Feb 2026 13:29:54 +0100 Subject: [PATCH 01/11] feat: Support device_id as bucketing identifier for local evaluation Add support for `bucketing_identifier` field on feature flags to allow using `device_id` instead of `distinct_id` for hashing/bucketing in local evaluation. - When `bucketing_identifier: "device_id"`, use device_id for hash calculations instead of distinct_id - device_id can be passed as method parameter or resolved from context via `get_context_device_id()` - If device_id is required but not provided, raises InconclusiveMatchError to trigger server fallback - Group flags ignore bucketing_identifier and always use group identifier --- posthog/client.py | 19 +- posthog/feature_flags.py | 62 ++++- posthog/test/test_feature_flags.py | 379 +++++++++++++++++++++++++++++ 3 files changed, 448 insertions(+), 12 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index 64db985d..e0da3825 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1418,6 +1418,7 @@ def _compute_flag_locally( person_properties=None, group_properties=None, warn_on_unknown_groups=True, + device_id=None, ) -> FlagValue: groups = groups or {} person_properties = person_properties or {} @@ -1459,12 +1460,14 @@ def _compute_flag_locally( return False focused_group_properties = group_properties[group_name] + # Group flags use group identifier for hashing, ignore bucketing_identifier return match_feature_flag_properties( feature_flag, groups[group_name], focused_group_properties, self.feature_flags_by_key, evaluation_cache, + skip_bucketing_identifier=True, ) else: return match_feature_flag_properties( @@ -1474,6 +1477,7 @@ def _compute_flag_locally( self.cohorts, self.feature_flags_by_key, evaluation_cache, + device_id=device_id, ) def feature_enabled( @@ -1580,8 +1584,12 @@ def _get_feature_flag_result( evaluated_at = None feature_flag_error: Optional[str] = None + # Resolve device_id from context if not provided + if device_id is None: + device_id = get_context_device_id() + flag_value = self._locally_evaluate_flag( - key, distinct_id, groups, person_properties, group_properties + key, distinct_id, groups, person_properties, group_properties, device_id ) flag_was_locally_evaluated = flag_value is not None @@ -1785,6 +1793,7 @@ def _locally_evaluate_flag( groups: dict[str, str], person_properties: dict[str, str], group_properties: dict[str, str], + device_id: Optional[str] = None, ) -> Optional[FlagValue]: if self.feature_flags is None and self.personal_api_key: self.load_feature_flags() @@ -1804,6 +1813,7 @@ def _locally_evaluate_flag( groups=groups, person_properties=person_properties, group_properties=group_properties, + device_id=device_id, ) self.log.debug( f"Successfully computed flag locally: {key} -> {response}" @@ -2106,12 +2116,17 @@ def get_all_flags_and_payloads( ) ) + # Resolve device_id from context if not provided + if device_id is None: + device_id = get_context_device_id() + response, fallback_to_flags = self._get_all_flags_and_payloads_locally( distinct_id, groups=groups, person_properties=person_properties, group_properties=group_properties, flag_keys_to_evaluate=flag_keys_to_evaluate, + device_id=device_id, ) if fallback_to_flags and not only_evaluate_locally: @@ -2142,6 +2157,7 @@ def _get_all_flags_and_payloads_locally( group_properties=None, warn_on_unknown_groups=False, flag_keys_to_evaluate: Optional[list[str]] = None, + device_id: Optional[str] = None, ) -> tuple[FlagsAndPayloads, bool]: person_properties = person_properties or {} group_properties = group_properties or {} @@ -2171,6 +2187,7 @@ def _get_all_flags_and_payloads_locally( person_properties=person_properties, group_properties=group_properties, warn_on_unknown_groups=warn_on_unknown_groups, + device_id=device_id, ) matched_payload = self._compute_payload_locally( flag["key"], flags[flag["key"]] diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index ef850e60..68775f7a 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -44,8 +44,8 @@ def _hash(key: str, distinct_id: str, salt: str = "") -> float: return hash_val / __LONG_SCALE__ -def get_matching_variant(flag, distinct_id): - hash_value = _hash(flag["key"], distinct_id, salt="variant") +def get_matching_variant(flag, hashing_identifier): + hash_value = _hash(flag["key"], hashing_identifier, salt="variant") for variant in variant_lookup_table(flag): if hash_value >= variant["value_min"] and hash_value < variant["value_max"]: return variant["key"] @@ -68,7 +68,13 @@ def variant_lookup_table(feature_flag): def evaluate_flag_dependency( - property, flags_by_key, evaluation_cache, distinct_id, properties, cohort_properties + property, + flags_by_key, + evaluation_cache, + distinct_id, + properties, + cohort_properties, + device_id=None, ): """ Evaluate a flag dependency property according to the dependency chain algorithm. @@ -80,6 +86,7 @@ def evaluate_flag_dependency( distinct_id: The distinct ID being evaluated properties: Person properties for evaluation cohort_properties: Cohort properties for evaluation + device_id: The device ID for bucketing (optional) Returns: bool: True if all dependencies in the chain evaluate to True, False otherwise @@ -131,6 +138,7 @@ def evaluate_flag_dependency( cohort_properties, flags_by_key, evaluation_cache, + device_id=device_id, ) evaluation_cache[dep_flag_key] = dep_result except InconclusiveMatchError as e: @@ -222,16 +230,32 @@ def match_feature_flag_properties( cohort_properties=None, flags_by_key=None, evaluation_cache=None, + device_id=None, + skip_bucketing_identifier=False, ) -> FlagValue: - flag_conditions = (flag.get("filters") or {}).get("groups") or [] + flag_filters = flag.get("filters") or {} + flag_conditions = flag_filters.get("groups") or [] is_inconclusive = False cohort_properties = cohort_properties or {} # Some filters can be explicitly set to null, which require accessing variants like so - flag_variants = ((flag.get("filters") or {}).get("multivariate") or {}).get( - "variants" - ) or [] + flag_variants = (flag_filters.get("multivariate") or {}).get("variants") or [] valid_variant_keys = [variant["key"] for variant in flag_variants] + # Determine the hashing identifier based on bucketing_identifier setting + # For group flags, skip_bucketing_identifier is True and we always use the passed identifier + if skip_bucketing_identifier: + hashing_identifier = distinct_id + else: + bucketing_identifier = flag_filters.get("bucketing_identifier") + if bucketing_identifier == "device_id": + if not device_id: + raise InconclusiveMatchError( + "Flag requires device_id for bucketing but none was provided" + ) + hashing_identifier = device_id + else: + hashing_identifier = distinct_id + for condition in flag_conditions: try: # if any one condition resolves to True, we can shortcircuit and return @@ -244,12 +268,14 @@ def match_feature_flag_properties( cohort_properties, flags_by_key, evaluation_cache, + hashing_identifier=hashing_identifier, + device_id=device_id, ): variant_override = condition.get("variant") if variant_override and variant_override in valid_variant_keys: variant = variant_override else: - variant = get_matching_variant(flag, distinct_id) + variant = get_matching_variant(flag, hashing_identifier) return variant or True except RequiresServerEvaluation: # Static cohort or other missing server-side data - must fallback to API @@ -277,7 +303,13 @@ def is_condition_match( cohort_properties, flags_by_key=None, evaluation_cache=None, + hashing_identifier=None, + device_id=None, ) -> bool: + # Use hashing_identifier if provided, otherwise fall back to distinct_id + if hashing_identifier is None: + hashing_identifier = distinct_id + rollout_percentage = condition.get("rollout_percentage") if len(condition.get("properties") or []) > 0: for prop in condition.get("properties"): @@ -290,6 +322,7 @@ def is_condition_match( flags_by_key, evaluation_cache, distinct_id, + device_id=device_id, ) elif property_type == "flag": matches = evaluate_flag_dependency( @@ -299,6 +332,7 @@ def is_condition_match( distinct_id, properties, cohort_properties, + device_id=device_id, ) else: matches = match_property(prop, properties) @@ -308,9 +342,9 @@ def is_condition_match( if rollout_percentage is None: return True - if rollout_percentage is not None and _hash(feature_flag["key"], distinct_id) > ( - rollout_percentage / 100 - ): + if rollout_percentage is not None and _hash( + feature_flag["key"], hashing_identifier + ) > (rollout_percentage / 100): return False return True @@ -454,6 +488,7 @@ def match_cohort( flags_by_key=None, evaluation_cache=None, distinct_id=None, + device_id=None, ) -> bool: # Cohort properties are in the form of property groups like this: # { @@ -478,6 +513,7 @@ def match_cohort( flags_by_key, evaluation_cache, distinct_id, + device_id=device_id, ) @@ -488,6 +524,7 @@ def match_property_group( flags_by_key=None, evaluation_cache=None, distinct_id=None, + device_id=None, ) -> bool: if not property_group: return True @@ -512,6 +549,7 @@ def match_property_group( flags_by_key, evaluation_cache, distinct_id, + device_id=device_id, ) if property_group_type == "AND": if not matches: @@ -545,6 +583,7 @@ def match_property_group( flags_by_key, evaluation_cache, distinct_id, + device_id=device_id, ) elif prop.get("type") == "flag": matches = evaluate_flag_dependency( @@ -554,6 +593,7 @@ def match_property_group( distinct_id, property_values, cohort_properties, + device_id=device_id, ) else: matches = match_property(prop, property_values) diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 783793f8..5eb7968a 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -3220,6 +3220,385 @@ def test_fallback_to_api_when_flag_has_static_cohort_in_multi_condition( # Verify API was called (fallback occurred) self.assertEqual(patch_flags.call_count, 1) + @mock.patch("posthog.client.flags") + def test_device_id_bucketing_uses_device_id_for_hash(self, patch_flags): + """ + When a flag has bucketing_identifier: "device_id", the device_id should be + used for hashing instead of distinct_id. + """ + client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) + + # This flag uses device_id for bucketing + client.feature_flags = [ + { + "id": 1, + "key": "device-bucketed-flag", + "active": True, + "filters": { + "bucketing_identifier": "device_id", + "groups": [ + { + "properties": [], + "rollout_percentage": 100, + } + ], + }, + } + ] + + # Same distinct_id with different device_ids should produce different results + # (based on rollout percentage, we check consistency) + result1 = client.get_feature_flag( + "device-bucketed-flag", "user-123", device_id="device-A" + ) + result2 = client.get_feature_flag( + "device-bucketed-flag", "user-123", device_id="device-A" + ) + + # Same device_id should give consistent results + self.assertEqual(result1, result2) + + # No API fallback should occur + self.assertEqual(patch_flags.call_count, 0) + + @mock.patch("posthog.client.flags") + def test_device_id_bucketing_same_device_different_users_same_result( + self, patch_flags + ): + """ + When a flag uses device_id bucketing, different distinct_ids with the same + device_id should get the same result. + """ + client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) + + client.feature_flags = [ + { + "id": 1, + "key": "device-bucketed-flag", + "active": True, + "filters": { + "bucketing_identifier": "device_id", + "groups": [ + { + "properties": [], + "rollout_percentage": 50, + } + ], + }, + } + ] + + # Different distinct_ids with the same device_id should get the same result + result1 = client.get_feature_flag( + "device-bucketed-flag", "user-A", device_id="shared-device" + ) + result2 = client.get_feature_flag( + "device-bucketed-flag", "user-B", device_id="shared-device" + ) + result3 = client.get_feature_flag( + "device-bucketed-flag", "user-C", device_id="shared-device" + ) + + # All should be the same since device_id is the same + self.assertEqual(result1, result2) + self.assertEqual(result2, result3) + + self.assertEqual(patch_flags.call_count, 0) + + @mock.patch("posthog.client.flags") + def test_device_id_bucketing_fallback_when_device_id_missing(self, patch_flags): + """ + When a flag requires device_id for bucketing but none is provided, + it should fallback to server evaluation. + """ + patch_flags.return_value = {"featureFlags": {"device-bucketed-flag": True}} + client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) + + client.feature_flags = [ + { + "id": 1, + "key": "device-bucketed-flag", + "active": True, + "filters": { + "bucketing_identifier": "device_id", + "groups": [ + { + "properties": [], + "rollout_percentage": 100, + } + ], + }, + } + ] + + # No device_id provided - should fallback to API + result = client.get_feature_flag("device-bucketed-flag", "user-123") + + self.assertTrue(result) + # API should have been called + self.assertEqual(patch_flags.call_count, 1) + + @mock.patch("posthog.client.flags") + def test_device_id_bucketing_returns_none_when_only_evaluate_locally_and_no_device_id( + self, patch_flags + ): + """ + When only_evaluate_locally=True and device_id is required but missing, + should return None instead of falling back to API. + """ + client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) + + client.feature_flags = [ + { + "id": 1, + "key": "device-bucketed-flag", + "active": True, + "filters": { + "bucketing_identifier": "device_id", + "groups": [ + { + "properties": [], + "rollout_percentage": 100, + } + ], + }, + } + ] + + # No device_id + only_evaluate_locally should return None + result = client.get_feature_flag( + "device-bucketed-flag", "user-123", only_evaluate_locally=True + ) + + self.assertIsNone(result) + # API should NOT have been called + self.assertEqual(patch_flags.call_count, 0) + + @mock.patch("posthog.client.flags") + def test_default_bucketing_identifier_uses_distinct_id(self, patch_flags): + """ + When bucketing_identifier is not set or is 'distinct_id', should use + distinct_id for hashing (default behavior). + """ + client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) + + # Flag without bucketing_identifier (defaults to distinct_id) + client.feature_flags = [ + { + "id": 1, + "key": "normal-flag", + "active": True, + "filters": { + "groups": [ + { + "properties": [], + "rollout_percentage": 50, + } + ], + }, + } + ] + + # Different distinct_ids should potentially produce different results + # but same distinct_id should produce same result + result1 = client.get_feature_flag("normal-flag", "user-A") + result2 = client.get_feature_flag("normal-flag", "user-A") + + self.assertEqual(result1, result2) + self.assertEqual(patch_flags.call_count, 0) + + @mock.patch("posthog.client.flags") + def test_device_id_bucketing_with_multivariate_flag(self, patch_flags): + """ + Multivariate flag variant selection should use device_id when + bucketing_identifier is set to device_id. + """ + client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) + + client.feature_flags = [ + { + "id": 1, + "key": "multivariate-device-flag", + "active": True, + "filters": { + "bucketing_identifier": "device_id", + "groups": [ + { + "properties": [], + "rollout_percentage": 100, + } + ], + "multivariate": { + "variants": [ + {"key": "control", "rollout_percentage": 50}, + {"key": "test", "rollout_percentage": 50}, + ] + }, + }, + } + ] + + # Same device_id should give same variant + result1 = client.get_feature_flag( + "multivariate-device-flag", "user-A", device_id="device-1" + ) + result2 = client.get_feature_flag( + "multivariate-device-flag", "user-B", device_id="device-1" + ) + + # Both should get the same variant because device_id is the same + self.assertEqual(result1, result2) + self.assertIn(result1, ["control", "test"]) + + self.assertEqual(patch_flags.call_count, 0) + + @mock.patch("posthog.client.flags") + def test_device_id_bucketing_from_context(self, patch_flags): + """ + When device_id is not passed as a parameter but is set in the context, + it should be resolved from context. + """ + from posthog.contexts import new_context, set_context_device_id + + client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) + + client.feature_flags = [ + { + "id": 1, + "key": "device-bucketed-flag", + "active": True, + "filters": { + "bucketing_identifier": "device_id", + "groups": [ + { + "properties": [], + "rollout_percentage": 100, + } + ], + }, + } + ] + + # Set device_id in context + with new_context(): + set_context_device_id("context-device-id") + result = client.get_feature_flag("device-bucketed-flag", "user-123") + + # Should evaluate locally using the context device_id + self.assertTrue(result) + self.assertEqual(patch_flags.call_count, 0) + + @mock.patch("posthog.client.flags") + def test_group_flags_ignore_bucketing_identifier(self, patch_flags): + """ + Group flags should continue to use the group identifier for hashing, + regardless of the bucketing_identifier setting. + """ + client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) + + client.feature_flags = [ + { + "id": 1, + "key": "group-flag", + "active": True, + "filters": { + "aggregation_group_type_index": 0, + "bucketing_identifier": "device_id", # Should be ignored for group flags + "groups": [ + { + "properties": [], + "rollout_percentage": 100, + } + ], + }, + } + ] + client.group_type_mapping = {"0": "company"} + + # Even with bucketing_identifier set to device_id, group flag should use group identifier + result = client.get_feature_flag( + "group-flag", + "user-123", + groups={"company": "acme-inc"}, + device_id="some-device", + ) + + self.assertTrue(result) + self.assertEqual(patch_flags.call_count, 0) + + @mock.patch("posthog.client.flags") + def test_get_all_flags_with_device_id_bucketing(self, patch_flags): + """ + get_all_flags_and_payloads should properly handle flags with device_id bucketing. + """ + client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) + + client.feature_flags = [ + { + "id": 1, + "key": "normal-flag", + "active": True, + "filters": { + "groups": [{"properties": [], "rollout_percentage": 100}], + }, + }, + { + "id": 2, + "key": "device-flag", + "active": True, + "filters": { + "bucketing_identifier": "device_id", + "groups": [{"properties": [], "rollout_percentage": 100}], + }, + }, + ] + + # With device_id provided, both flags should be evaluated locally + result = client.get_all_flags("user-123", device_id="my-device") + + self.assertEqual(result["normal-flag"], True) + self.assertEqual(result["device-flag"], True) + self.assertEqual(patch_flags.call_count, 0) + + @mock.patch("posthog.client.flags") + def test_get_all_flags_fallback_when_device_id_missing_for_some_flags( + self, patch_flags + ): + """ + When some flags require device_id but it's not provided, those flags + should trigger fallback while others can be evaluated locally. + """ + patch_flags.return_value = { + "featureFlags": {"normal-flag": True, "device-flag": "from-api"} + } + client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) + + client.feature_flags = [ + { + "id": 1, + "key": "normal-flag", + "active": True, + "filters": { + "groups": [{"properties": [], "rollout_percentage": 100}], + }, + }, + { + "id": 2, + "key": "device-flag", + "active": True, + "filters": { + "bucketing_identifier": "device_id", + "groups": [{"properties": [], "rollout_percentage": 100}], + }, + }, + ] + + # Without device_id, device-flag can't be evaluated locally + client.get_all_flags("user-123") + + # Should fallback to API for all flags when any can't be evaluated locally + self.assertEqual(patch_flags.call_count, 1) + class TestMatchProperties(unittest.TestCase): def property(self, key, value, operator=None): From a02205b4dae3e1ee48ec1438a9d72ab975926c1d Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Fri, 6 Feb 2026 10:53:08 +0100 Subject: [PATCH 02/11] refactor: Clean up hashing identifier abstractions in feature flag evaluation - Rename _hash param from distinct_id to identifier since it now receives device IDs, group keys, and distinct IDs - Replace skip_bucketing_identifier boolean with explicit hashing_identifier param so callers pass the resolved identifier directly for group flags - Make hashing_identifier a required keyword arg in is_condition_match, removing a dead fallback that silently masked potential bugs Co-Authored-By: Claude Opus 4.6 --- posthog/client.py | 3 +-- posthog/feature_flags.py | 28 ++++++++++++---------------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index e0da3825..27c63504 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1460,14 +1460,13 @@ def _compute_flag_locally( return False focused_group_properties = group_properties[group_name] - # Group flags use group identifier for hashing, ignore bucketing_identifier return match_feature_flag_properties( feature_flag, groups[group_name], focused_group_properties, self.feature_flags_by_key, evaluation_cache, - skip_bucketing_identifier=True, + hashing_identifier=groups[group_name], ) else: return match_feature_flag_properties( diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index 68775f7a..101d73d7 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -34,12 +34,12 @@ class RequiresServerEvaluation(Exception): pass -# This function takes a distinct_id and a feature flag key and returns a float between 0 and 1. -# Given the same distinct_id and key, it'll always return the same float. These floats are +# This function takes an identifier and a feature flag key and returns a float between 0 and 1. +# Given the same identifier and key, it'll always return the same float. These floats are # uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic -# we can do _hash(key, distinct_id) < 0.2 -def _hash(key: str, distinct_id: str, salt: str = "") -> float: - hash_key = f"{key}.{distinct_id}{salt}" +# we can do _hash(key, identifier) < 0.2 +def _hash(key: str, identifier: str, salt: str = "") -> float: + hash_key = f"{key}.{identifier}{salt}" hash_val = int(hashlib.sha1(hash_key.encode("utf-8")).hexdigest()[:15], 16) return hash_val / __LONG_SCALE__ @@ -231,7 +231,7 @@ def match_feature_flag_properties( flags_by_key=None, evaluation_cache=None, device_id=None, - skip_bucketing_identifier=False, + hashing_identifier=None, ) -> FlagValue: flag_filters = flag.get("filters") or {} flag_conditions = flag_filters.get("groups") or [] @@ -241,11 +241,10 @@ def match_feature_flag_properties( flag_variants = (flag_filters.get("multivariate") or {}).get("variants") or [] valid_variant_keys = [variant["key"] for variant in flag_variants] - # Determine the hashing identifier based on bucketing_identifier setting - # For group flags, skip_bucketing_identifier is True and we always use the passed identifier - if skip_bucketing_identifier: - hashing_identifier = distinct_id - else: + # Determine the hashing identifier: + # - If caller provided one explicitly (e.g. group key for group flags), use it directly + # - Otherwise resolve from the flag's bucketing_identifier setting + if hashing_identifier is None: bucketing_identifier = flag_filters.get("bucketing_identifier") if bucketing_identifier == "device_id": if not device_id: @@ -303,13 +302,10 @@ def is_condition_match( cohort_properties, flags_by_key=None, evaluation_cache=None, - hashing_identifier=None, + *, + hashing_identifier, device_id=None, ) -> bool: - # Use hashing_identifier if provided, otherwise fall back to distinct_id - if hashing_identifier is None: - hashing_identifier = distinct_id - rollout_percentage = condition.get("rollout_percentage") if len(condition.get("properties") or []) > 0: for prop in condition.get("properties"): From 5dfbfb6cb5fbe51b027125f54af3a79d27a40163 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Mon, 9 Feb 2026 12:03:10 +0100 Subject: [PATCH 03/11] rename variable --- posthog/client.py | 2 +- posthog/feature_flags.py | 32 ++++++++++++++++---------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index 27c63504..17c45fe7 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1466,7 +1466,7 @@ def _compute_flag_locally( focused_group_properties, self.feature_flags_by_key, evaluation_cache, - hashing_identifier=groups[group_name], + bucketing_value=groups[group_name], ) else: return match_feature_flag_properties( diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index 101d73d7..31ac0a6e 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -34,18 +34,18 @@ class RequiresServerEvaluation(Exception): pass -# This function takes an identifier and a feature flag key and returns a float between 0 and 1. -# Given the same identifier and key, it'll always return the same float. These floats are +# This function takes a bucketing value and a feature flag key and returns a float between 0 and 1. +# Given the same bucketing value and key, it'll always return the same float. These floats are # uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic -# we can do _hash(key, identifier) < 0.2 -def _hash(key: str, identifier: str, salt: str = "") -> float: - hash_key = f"{key}.{identifier}{salt}" +# we can do _hash(key, bucketing_value) < 0.2 +def _hash(key: str, bucketing_value: str, salt: str = "") -> float: + hash_key = f"{key}.{bucketing_value}{salt}" hash_val = int(hashlib.sha1(hash_key.encode("utf-8")).hexdigest()[:15], 16) return hash_val / __LONG_SCALE__ -def get_matching_variant(flag, hashing_identifier): - hash_value = _hash(flag["key"], hashing_identifier, salt="variant") +def get_matching_variant(flag, bucketing_value): + hash_value = _hash(flag["key"], bucketing_value, salt="variant") for variant in variant_lookup_table(flag): if hash_value >= variant["value_min"] and hash_value < variant["value_max"]: return variant["key"] @@ -231,7 +231,7 @@ def match_feature_flag_properties( flags_by_key=None, evaluation_cache=None, device_id=None, - hashing_identifier=None, + bucketing_value=None, ) -> FlagValue: flag_filters = flag.get("filters") or {} flag_conditions = flag_filters.get("groups") or [] @@ -241,19 +241,19 @@ def match_feature_flag_properties( flag_variants = (flag_filters.get("multivariate") or {}).get("variants") or [] valid_variant_keys = [variant["key"] for variant in flag_variants] - # Determine the hashing identifier: + # Determine the bucketing value: # - If caller provided one explicitly (e.g. group key for group flags), use it directly # - Otherwise resolve from the flag's bucketing_identifier setting - if hashing_identifier is None: + if bucketing_value is None: bucketing_identifier = flag_filters.get("bucketing_identifier") if bucketing_identifier == "device_id": if not device_id: raise InconclusiveMatchError( "Flag requires device_id for bucketing but none was provided" ) - hashing_identifier = device_id + bucketing_value = device_id else: - hashing_identifier = distinct_id + bucketing_value = distinct_id for condition in flag_conditions: try: @@ -267,14 +267,14 @@ def match_feature_flag_properties( cohort_properties, flags_by_key, evaluation_cache, - hashing_identifier=hashing_identifier, + bucketing_value=bucketing_value, device_id=device_id, ): variant_override = condition.get("variant") if variant_override and variant_override in valid_variant_keys: variant = variant_override else: - variant = get_matching_variant(flag, hashing_identifier) + variant = get_matching_variant(flag, bucketing_value) return variant or True except RequiresServerEvaluation: # Static cohort or other missing server-side data - must fallback to API @@ -303,7 +303,7 @@ def is_condition_match( flags_by_key=None, evaluation_cache=None, *, - hashing_identifier, + bucketing_value, device_id=None, ) -> bool: rollout_percentage = condition.get("rollout_percentage") @@ -339,7 +339,7 @@ def is_condition_match( return True if rollout_percentage is not None and _hash( - feature_flag["key"], hashing_identifier + feature_flag["key"], bucketing_value ) > (rollout_percentage / 100): return False From f6887975b296e1596644f5cd57456a1a8b2086bd Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Tue, 10 Feb 2026 09:51:19 +0100 Subject: [PATCH 04/11] fix: Correct positional arg order in group flag evaluation The group path in _compute_flag_locally was passing positional args in the wrong order to match_feature_flag_properties: feature_flags_by_key landed in cohort_properties and evaluation_cache landed in flags_by_key. Switch to keyword args to make the mapping explicit and correct. --- posthog/client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index 17c45fe7..86377579 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1464,9 +1464,9 @@ def _compute_flag_locally( feature_flag, groups[group_name], focused_group_properties, - self.feature_flags_by_key, - evaluation_cache, - bucketing_value=groups[group_name], + cohort_properties=self.cohorts, + flags_by_key=self.feature_flags_by_key, + evaluation_cache=evaluation_cache, ) else: return match_feature_flag_properties( From 4f1703532ee291d06ebb6e898f39e38f74a64e74 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Tue, 10 Feb 2026 10:07:30 +0100 Subject: [PATCH 05/11] force named arguments --- posthog/feature_flags.py | 1 + 1 file changed, 1 insertion(+) diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index 31ac0a6e..6b858ca5 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -227,6 +227,7 @@ def match_feature_flag_properties( flag, distinct_id, properties, + *, cohort_properties=None, flags_by_key=None, evaluation_cache=None, From 229a104c5f763f5ae79f1faf23353aca41fbaa6b Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Tue, 10 Feb 2026 10:39:50 +0100 Subject: [PATCH 06/11] fix: Use keyword arguments for match_feature_flag_properties callers Update call sites to use keyword arguments after the positional-to-keyword enforcement change in match_feature_flag_properties. --- posthog/client.py | 6 +++--- posthog/feature_flags.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index 86377579..1dafe007 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1473,9 +1473,9 @@ def _compute_flag_locally( feature_flag, distinct_id, person_properties, - self.cohorts, - self.feature_flags_by_key, - evaluation_cache, + cohort_properties=self.cohorts, + flags_by_key=self.feature_flags_by_key, + evaluation_cache=evaluation_cache, device_id=device_id, ) diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index 6b858ca5..dd5fafab 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -135,9 +135,9 @@ def evaluate_flag_dependency( dep_flag, distinct_id, properties, - cohort_properties, - flags_by_key, - evaluation_cache, + cohort_properties=cohort_properties, + flags_by_key=flags_by_key, + evaluation_cache=evaluation_cache, device_id=device_id, ) evaluation_cache[dep_flag_key] = dep_result From 163e9861e6b9b47561a764515ec0d6e7a134a625 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Tue, 10 Feb 2026 11:09:37 +0100 Subject: [PATCH 07/11] skip bucket value resolution when value is setQ --- posthog/client.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index 1dafe007..555ac096 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1459,14 +1459,16 @@ def _compute_flag_locally( ) return False - focused_group_properties = group_properties[group_name] + focused_group_properties = group_properties.get(group_name, {}) + group_key = groups[group_name] return match_feature_flag_properties( feature_flag, - groups[group_name], + group_key, focused_group_properties, cohort_properties=self.cohorts, flags_by_key=self.feature_flags_by_key, evaluation_cache=evaluation_cache, + bucketing_value=group_key, ) else: return match_feature_flag_properties( From 7934e9c410777a98360bc629e4a4e90741a24a48 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Tue, 10 Feb 2026 12:04:54 +0100 Subject: [PATCH 08/11] Refactor: resolve bucketing value upfront via helper function Extract bucketing value resolution into a single resolve_bucketing_value() helper, called in _compute_flag_locally and evaluate_flag_dependency, instead of resolving it inside match_feature_flag_properties. bucketing_value is now a required keyword argument on match_feature_flag_properties. --- posthog/client.py | 3 +++ posthog/feature_flags.py | 38 +++++++++++++++++++++++--------------- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index 555ac096..a32bf7c9 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -38,6 +38,7 @@ InconclusiveMatchError, RequiresServerEvaluation, match_feature_flag_properties, + resolve_bucketing_value, ) from posthog.flag_definition_cache import ( FlagDefinitionCacheData, @@ -1471,6 +1472,7 @@ def _compute_flag_locally( bucketing_value=group_key, ) else: + bucketing_value = resolve_bucketing_value(feature_flag, distinct_id, device_id) return match_feature_flag_properties( feature_flag, distinct_id, @@ -1479,6 +1481,7 @@ def _compute_flag_locally( flags_by_key=self.feature_flags_by_key, evaluation_cache=evaluation_cache, device_id=device_id, + bucketing_value=bucketing_value, ) def feature_enabled( diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index dd5fafab..689e79ed 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -131,6 +131,7 @@ def evaluate_flag_dependency( else: # Recursively evaluate the dependency try: + dep_bucketing_value = resolve_bucketing_value(dep_flag, distinct_id, device_id) dep_result = match_feature_flag_properties( dep_flag, distinct_id, @@ -139,6 +140,7 @@ def evaluate_flag_dependency( flags_by_key=flags_by_key, evaluation_cache=evaluation_cache, device_id=device_id, + bucketing_value=dep_bucketing_value, ) evaluation_cache[dep_flag_key] = dep_result except InconclusiveMatchError as e: @@ -223,6 +225,26 @@ def matches_dependency_value(expected_value, actual_value): return False +def resolve_bucketing_value(flag, distinct_id, device_id=None): + """Resolve the bucketing value for a flag based on its bucketing_identifier setting. + + Returns: + The appropriate identifier string to use for hashing/bucketing. + + Raises: + InconclusiveMatchError: If the flag requires device_id but none was provided. + """ + flag_filters = flag.get("filters") or {} + bucketing_identifier = flag_filters.get("bucketing_identifier") + if bucketing_identifier == "device_id": + if not device_id: + raise InconclusiveMatchError( + "Flag requires device_id for bucketing but none was provided" + ) + return device_id + return distinct_id + + def match_feature_flag_properties( flag, distinct_id, @@ -232,7 +254,7 @@ def match_feature_flag_properties( flags_by_key=None, evaluation_cache=None, device_id=None, - bucketing_value=None, + bucketing_value, ) -> FlagValue: flag_filters = flag.get("filters") or {} flag_conditions = flag_filters.get("groups") or [] @@ -242,20 +264,6 @@ def match_feature_flag_properties( flag_variants = (flag_filters.get("multivariate") or {}).get("variants") or [] valid_variant_keys = [variant["key"] for variant in flag_variants] - # Determine the bucketing value: - # - If caller provided one explicitly (e.g. group key for group flags), use it directly - # - Otherwise resolve from the flag's bucketing_identifier setting - if bucketing_value is None: - bucketing_identifier = flag_filters.get("bucketing_identifier") - if bucketing_identifier == "device_id": - if not device_id: - raise InconclusiveMatchError( - "Flag requires device_id for bucketing but none was provided" - ) - bucketing_value = device_id - else: - bucketing_value = distinct_id - for condition in flag_conditions: try: # if any one condition resolves to True, we can shortcircuit and return From f450446876ff1dd831647bbd4065a1ef01ee6c3c Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Tue, 10 Feb 2026 12:18:06 +0100 Subject: [PATCH 09/11] format --- posthog/client.py | 4 +++- posthog/feature_flags.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index a32bf7c9..e77c2bd1 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1472,7 +1472,9 @@ def _compute_flag_locally( bucketing_value=group_key, ) else: - bucketing_value = resolve_bucketing_value(feature_flag, distinct_id, device_id) + bucketing_value = resolve_bucketing_value( + feature_flag, distinct_id, device_id + ) return match_feature_flag_properties( feature_flag, distinct_id, diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index 689e79ed..651d60f3 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -131,7 +131,9 @@ def evaluate_flag_dependency( else: # Recursively evaluate the dependency try: - dep_bucketing_value = resolve_bucketing_value(dep_flag, distinct_id, device_id) + dep_bucketing_value = resolve_bucketing_value( + dep_flag, distinct_id, device_id + ) dep_result = match_feature_flag_properties( dep_flag, distinct_id, From 44a3d7f973e2b08a183b18399a8b82c065a5fd52 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Tue, 10 Feb 2026 17:58:36 +0100 Subject: [PATCH 10/11] fix(flags): preserve group dependency bucketing in local eval --- posthog/client.py | 1 + posthog/feature_flags.py | 13 +++- posthog/test/test_feature_flags.py | 120 +++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 2 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index e77c2bd1..c0eb93de 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1469,6 +1469,7 @@ def _compute_flag_locally( cohort_properties=self.cohorts, flags_by_key=self.feature_flags_by_key, evaluation_cache=evaluation_cache, + device_id=device_id, bucketing_value=group_key, ) else: diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index 651d60f3..68469b4c 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -131,9 +131,18 @@ def evaluate_flag_dependency( else: # Recursively evaluate the dependency try: - dep_bucketing_value = resolve_bucketing_value( - dep_flag, distinct_id, device_id + dep_flag_filters = dep_flag.get("filters") or {} + dep_aggregation_group_type_index = dep_flag_filters.get( + "aggregation_group_type_index" ) + if dep_aggregation_group_type_index is not None: + # Group flags should continue bucketing by the group key + # from the current evaluation context. + dep_bucketing_value = distinct_id + else: + dep_bucketing_value = resolve_bucketing_value( + dep_flag, distinct_id, device_id + ) dep_result = match_feature_flag_properties( dep_flag, distinct_id, diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 5eb7968a..a37ab131 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -3526,6 +3526,126 @@ def test_group_flags_ignore_bucketing_identifier(self, patch_flags): self.assertTrue(result) self.assertEqual(patch_flags.call_count, 0) + @mock.patch("posthog.client.flags") + def test_group_flag_dependency_receives_device_id(self, patch_flags): + """ + Group flag dependency evaluation should receive device_id so dependent + device_id-bucketed flags can be evaluated locally. + """ + patch_flags.return_value = {"featureFlags": {"group-parent-flag": "from-api"}} + client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) + + client.feature_flags = [ + { + "id": 1, + "key": "device-dependent-flag", + "active": True, + "filters": { + "bucketing_identifier": "device_id", + "groups": [ + { + "properties": [], + "rollout_percentage": 100, + } + ], + }, + }, + { + "id": 2, + "key": "group-parent-flag", + "active": True, + "filters": { + "aggregation_group_type_index": 0, + "groups": [ + { + "properties": [ + { + "key": "device-dependent-flag", + "operator": "flag_evaluates_to", + "value": True, + "type": "flag", + "dependency_chain": ["device-dependent-flag"], + } + ], + "rollout_percentage": 100, + } + ], + }, + }, + ] + client.group_type_mapping = {"0": "company"} + + result = client.get_feature_flag( + "group-parent-flag", + "user-123", + groups={"company": "acme-inc"}, + device_id="device-123", + ) + + self.assertTrue(result) + self.assertEqual(patch_flags.call_count, 0) + + @mock.patch("posthog.client.flags") + def test_group_flag_dependency_ignores_device_id_bucketing_identifier( + self, patch_flags + ): + """ + Group flag dependencies should keep bucketing by group key, even when + the dependent group flag has bucketing_identifier set to device_id. + """ + patch_flags.return_value = {"featureFlags": {"parent-group-flag": "from-api"}} + client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) + + client.feature_flags = [ + { + "id": 1, + "key": "child-group-flag", + "active": True, + "filters": { + "aggregation_group_type_index": 0, + "bucketing_identifier": "device_id", + "groups": [ + { + "properties": [], + "rollout_percentage": 100, + } + ], + }, + }, + { + "id": 2, + "key": "parent-group-flag", + "active": True, + "filters": { + "aggregation_group_type_index": 0, + "groups": [ + { + "properties": [ + { + "key": "child-group-flag", + "operator": "flag_evaluates_to", + "value": True, + "type": "flag", + "dependency_chain": ["child-group-flag"], + } + ], + "rollout_percentage": 100, + } + ], + }, + }, + ] + client.group_type_mapping = {"0": "company"} + + result = client.get_feature_flag( + "parent-group-flag", + "user-123", + groups={"company": "acme-inc"}, + ) + + self.assertTrue(result) + self.assertEqual(patch_flags.call_count, 0) + @mock.patch("posthog.client.flags") def test_get_all_flags_with_device_id_bucketing(self, patch_flags): """ From def014c420efc45bdec0e31f29dac8c4d38de0db Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Wed, 11 Feb 2026 13:29:31 +0100 Subject: [PATCH 11/11] fix(flags): add deprecation fallback for missing bucketing_value --- posthog/feature_flags.py | 12 +++++++++- posthog/test/test_feature_flags.py | 36 ++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index 68469b4c..b4ca17bf 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -2,6 +2,7 @@ import hashlib import logging import re +import warnings from typing import Optional from dateutil import parser @@ -265,8 +266,17 @@ def match_feature_flag_properties( flags_by_key=None, evaluation_cache=None, device_id=None, - bucketing_value, + bucketing_value=None, ) -> FlagValue: + if bucketing_value is None: + warnings.warn( + "Calling match_feature_flag_properties() without bucketing_value is deprecated. " + "Pass bucketing_value explicitly. This fallback will be removed in a future major release.", + DeprecationWarning, + stacklevel=2, + ) + bucketing_value = resolve_bucketing_value(flag, distinct_id, device_id) + flag_filters = flag.get("filters") or {} flag_conditions = flag_filters.get("groups") or [] is_inconclusive = False diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index a37ab131..af3d9452 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -3261,6 +3261,42 @@ def test_device_id_bucketing_uses_device_id_for_hash(self, patch_flags): # No API fallback should occur self.assertEqual(patch_flags.call_count, 0) + def test_match_feature_flag_properties_without_bucketing_value_is_deprecated( + self, + ): + """ + match_feature_flag_properties should preserve backward compatibility when + bucketing_value is omitted, while warning about deprecation. + """ + from posthog.feature_flags import match_feature_flag_properties + + flag = { + "id": 1, + "key": "device-bucketed-flag", + "active": True, + "filters": { + "bucketing_identifier": "device_id", + "groups": [ + { + "properties": [], + "rollout_percentage": 100, + } + ], + }, + } + + with self.assertWarnsRegex( + DeprecationWarning, "without bucketing_value is deprecated" + ): + result = match_feature_flag_properties( + flag, + "user-123", + {}, + device_id="device-123", + ) + + self.assertTrue(result) + @mock.patch("posthog.client.flags") def test_device_id_bucketing_same_device_different_users_same_result( self, patch_flags