From 1aff66161f501c67bd0a27418c07ae83b2c2066e Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Thu, 5 Feb 2026 13:29:54 +0100 Subject: [PATCH 01/14] 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 1600c5af..846e33c0 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 042ddb4b..b4312f59 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -3223,6 +3223,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 631e14e00ef44b8d89d1b58d43a4da776da70d06 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Fri, 6 Feb 2026 10:53:08 +0100 Subject: [PATCH 02/14] 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 846e33c0..ebdb3ca4 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 8522ab7f6554dac9c1b9eee940d018eff7836bbf Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Mon, 9 Feb 2026 12:03:10 +0100 Subject: [PATCH 03/14] 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 ebdb3ca4..eb15e209 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 cd5f751c4173d5c5c1ac2fdf791c59fd8cef0b99 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Tue, 10 Feb 2026 09:51:19 +0100 Subject: [PATCH 04/14] 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 eb15e209..d7540eab 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 f47cfec027ec2376cffaece372eb0d2fdd5ef922 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Tue, 10 Feb 2026 10:07:30 +0100 Subject: [PATCH 05/14] 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 6d9a3f5b075aab95786c07672f93492eff4f38e5 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Tue, 10 Feb 2026 10:39:50 +0100 Subject: [PATCH 06/14] 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 d7540eab..634698f5 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 511329f6f1f74d58c8c69dca32fb0cffc3cf7175 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Tue, 10 Feb 2026 11:09:37 +0100 Subject: [PATCH 07/14] 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 634698f5..bbd32b22 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 7d65a0c56263cf34c8af9682a6b3e3ee558e1ada Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Tue, 10 Feb 2026 12:04:54 +0100 Subject: [PATCH 08/14] 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 bbd32b22..f166de1a 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 3a0b060d2e76e310106b3ff1ddc1547a15250dd1 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Tue, 10 Feb 2026 12:18:06 +0100 Subject: [PATCH 09/14] 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 f166de1a..ef65b157 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 5530d7ec3437629d13b8f1b5044f64cd77da8a7e Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Tue, 10 Feb 2026 17:58:36 +0100 Subject: [PATCH 10/14] 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 ef65b157..91ae4bb3 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 b4312f59..7209f930 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -3529,6 +3529,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 6915f4f4531530e29bf42273f9fbf66263fe3548 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Wed, 11 Feb 2026 13:29:31 +0100 Subject: [PATCH 11/14] 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 7209f930..509a6682 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -3264,6 +3264,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 From f73dae12dd7ee54b7720f34e05891feca020db08 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Wed, 11 Feb 2026 16:14:52 +0100 Subject: [PATCH 12/14] get bucketing identifier from the right place --- posthog/feature_flags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index b4ca17bf..ef0fc6ab 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -247,7 +247,7 @@ def resolve_bucketing_value(flag, distinct_id, device_id=None): InconclusiveMatchError: If the flag requires device_id but none was provided. """ flag_filters = flag.get("filters") or {} - bucketing_identifier = flag_filters.get("bucketing_identifier") + bucketing_identifier = flag.get("bucketing_identifier") or flag_filters.get("bucketing_identifier") if bucketing_identifier == "device_id": if not device_id: raise InconclusiveMatchError( From 7e2196a69d14eed3365760315f8490d048b0d97b Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Fri, 13 Feb 2026 10:54:23 +0100 Subject: [PATCH 13/14] fix(flags): fallback when group properties are missing --- posthog/client.py | 6 +++++- posthog/test/test_feature_flags.py | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/posthog/client.py b/posthog/client.py index 91ae4bb3..4de95581 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1460,7 +1460,11 @@ def _compute_flag_locally( ) return False - focused_group_properties = group_properties.get(group_name, {}) + if group_name not in group_properties: + raise InconclusiveMatchError( + f"Flag has no group properties for group '{group_name}'" + ) + focused_group_properties = group_properties[group_name] group_key = groups[group_name] return match_feature_flag_properties( feature_flag, diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 509a6682..1b0192ff 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -233,6 +233,27 @@ def test_flag_group_properties(self, patch_get, patch_flags): self.assertEqual(patch_flags.call_count, 1) + def test_group_flag_is_inconclusive_when_group_properties_missing(self): + feature_flag = { + "id": 1, + "name": "Group Flag Without Property Filters", + "key": "group-flag-no-props", + "active": True, + "filters": { + "aggregation_group_type_index": 0, + "groups": [{"properties": [], "rollout_percentage": 100}], + }, + } + self.client.group_type_mapping = {"0": "company"} + + with self.assertRaises(InconclusiveMatchError): + self.client._compute_flag_locally( + feature_flag, + "some-distinct-id", + groups={"company": "acme"}, + group_properties={}, + ) + @mock.patch("posthog.client.flags") @mock.patch("posthog.client.get") def test_flag_with_complex_definition(self, patch_get, patch_flags): From 3b465b06535a12137bee8046b7136b4e5ee4a3e4 Mon Sep 17 00:00:00 2001 From: Anders Asheim Hennum Date: Fri, 13 Feb 2026 11:19:11 +0100 Subject: [PATCH 14/14] format --- posthog/feature_flags.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index ef0fc6ab..93b8119e 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -247,7 +247,9 @@ def resolve_bucketing_value(flag, distinct_id, device_id=None): InconclusiveMatchError: If the flag requires device_id but none was provided. """ flag_filters = flag.get("filters") or {} - bucketing_identifier = flag.get("bucketing_identifier") or flag_filters.get("bucketing_identifier") + bucketing_identifier = flag.get("bucketing_identifier") or flag_filters.get( + "bucketing_identifier" + ) if bucketing_identifier == "device_id": if not device_id: raise InconclusiveMatchError(