From 6a23728e9a82ad64c2073451ff74d02c2db82318 Mon Sep 17 00:00:00 2001 From: Patricio Date: Fri, 17 Apr 2026 17:50:52 -0300 Subject: [PATCH 1/8] feat(flags): support mixed targeting in local evaluation --- posthog/client.py | 9 +- posthog/feature_flags.py | 40 ++++- posthog/test/test_feature_flags.py | 227 +++++++++++++++++++++++++++++ 3 files changed, 270 insertions(+), 6 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index 9672d9e4..8ec905c2 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1438,8 +1438,9 @@ def _compute_flag_locally( flag_filters = feature_flag.get("filters") or {} aggregation_group_type_index = flag_filters.get("aggregation_group_type_index") + group_type_mapping = self.group_type_mapping or {} + if aggregation_group_type_index is not None: - group_type_mapping = self.group_type_mapping or {} group_name = group_type_mapping.get(str(aggregation_group_type_index)) if not group_name: @@ -1477,6 +1478,9 @@ def _compute_flag_locally( evaluation_cache=evaluation_cache, device_id=device_id, bucketing_value=group_key, + group_type_mapping=group_type_mapping, + groups=groups, + group_properties=group_properties, ) else: bucketing_value = resolve_bucketing_value( @@ -1491,6 +1495,9 @@ def _compute_flag_locally( evaluation_cache=evaluation_cache, device_id=device_id, bucketing_value=bucketing_value, + group_type_mapping=group_type_mapping, + groups=groups, + group_properties=group_properties, ) def feature_enabled( diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index acd2d999..4d49e271 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -293,6 +293,9 @@ def match_feature_flag_properties( evaluation_cache=None, device_id=None, bucketing_value=None, + group_type_mapping=None, + groups=None, + group_properties=None, ) -> FlagValue: if bucketing_value is None: warnings.warn( @@ -305,32 +308,59 @@ def match_feature_flag_properties( flag_filters = flag.get("filters") or {} flag_conditions = flag_filters.get("groups") or [] + flag_aggregation = flag_filters.get("aggregation_group_type_index") is_inconclusive = False cohort_properties = cohort_properties or {} + groups = groups or {} + group_properties = group_properties or {} + group_type_mapping = group_type_mapping or {} # Some filters can be explicitly set to null, which require accessing variants like so flag_variants = (flag_filters.get("multivariate") or {}).get("variants") or [] valid_variant_keys = [variant["key"] for variant in flag_variants] for condition in flag_conditions: try: - # if any one condition resolves to True, we can shortcircuit and return - # the matching variant + # Per-condition aggregation overrides only when the condition explicitly + # sets its own aggregation_group_type_index (mixed targeting). + # When absent, use the properties/bucketing already resolved by the caller. + has_condition_aggregation = "aggregation_group_type_index" in condition + condition_aggregation = condition.get( + "aggregation_group_type_index", flag_aggregation + ) + + if has_condition_aggregation and condition_aggregation != flag_aggregation: + if condition_aggregation is not None: + group_name = group_type_mapping.get(str(condition_aggregation)) + if not group_name or group_name not in groups: + continue + if group_name not in group_properties: + is_inconclusive = True + continue + effective_properties = group_properties[group_name] + effective_bucketing = groups[group_name] + else: + effective_properties = properties + effective_bucketing = bucketing_value + else: + effective_properties = properties + effective_bucketing = bucketing_value + if is_condition_match( flag, distinct_id, condition, - properties, + effective_properties, cohort_properties, flags_by_key, evaluation_cache, - bucketing_value=bucketing_value, + bucketing_value=effective_bucketing, 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, bucketing_value) + variant = get_matching_variant(flag, effective_bucketing) return variant or True except RequiresServerEvaluation: # Static cohort or other missing server-side data - must fallback to API diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 2e7168ed..f4460fa4 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -3706,6 +3706,233 @@ def test_group_flag_dependency_ignores_device_id_bucketing_identifier( self.assertTrue(result) self.assertEqual(patch_flags.call_count, 0) + @mock.patch("posthog.client.flags") + def test_mixed_targeting_person_condition_matches(self, patch_flags): + client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) + client.feature_flags = [ + { + "id": 1, + "key": "mixed-flag", + "active": True, + "filters": { + "aggregation_group_type_index": None, + "groups": [ + { + "aggregation_group_type_index": 0, + "properties": [ + {"key": "plan", "value": "enterprise", "operator": "exact", "type": "group", "group_type_index": 0} + ], + "rollout_percentage": 100, + }, + { + "aggregation_group_type_index": None, + "properties": [ + {"key": "email", "value": "test@example.com", "operator": "exact", "type": "person"} + ], + "rollout_percentage": 100, + }, + ], + }, + } + ] + client.group_type_mapping = {"0": "company"} + + result = client.get_feature_flag( + "mixed-flag", + "user-123", + person_properties={"email": "test@example.com"}, + ) + self.assertTrue(result) + self.assertEqual(patch_flags.call_count, 0) + + @mock.patch("posthog.client.flags") + def test_mixed_targeting_group_condition_matches(self, patch_flags): + client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) + client.feature_flags = [ + { + "id": 1, + "key": "mixed-flag", + "active": True, + "filters": { + "aggregation_group_type_index": None, + "groups": [ + { + "aggregation_group_type_index": 0, + "properties": [ + {"key": "plan", "value": "enterprise", "operator": "exact", "type": "group", "group_type_index": 0} + ], + "rollout_percentage": 100, + }, + { + "aggregation_group_type_index": None, + "properties": [ + {"key": "email", "value": "test@example.com", "operator": "exact", "type": "person"} + ], + "rollout_percentage": 100, + }, + ], + }, + } + ] + client.group_type_mapping = {"0": "company"} + + result = client.get_feature_flag( + "mixed-flag", + "user-123", + groups={"company": "acme"}, + group_properties={"company": {"plan": "enterprise"}}, + ) + self.assertTrue(result) + self.assertEqual(patch_flags.call_count, 0) + + @mock.patch("posthog.client.flags") + def test_mixed_targeting_no_match_returns_false(self, patch_flags): + client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) + client.feature_flags = [ + { + "id": 1, + "key": "mixed-flag", + "active": True, + "filters": { + "aggregation_group_type_index": None, + "groups": [ + { + "aggregation_group_type_index": 0, + "properties": [ + {"key": "plan", "value": "enterprise", "operator": "exact", "type": "group", "group_type_index": 0} + ], + "rollout_percentage": 100, + }, + { + "aggregation_group_type_index": None, + "properties": [ + {"key": "email", "value": "test@example.com", "operator": "exact", "type": "person"} + ], + "rollout_percentage": 100, + }, + ], + }, + } + ] + client.group_type_mapping = {"0": "company"} + + result = client.get_feature_flag( + "mixed-flag", + "user-123", + person_properties={"email": "wrong@example.com"}, + groups={"company": "acme"}, + group_properties={"company": {"plan": "free"}}, + ) + self.assertFalse(result) + self.assertEqual(patch_flags.call_count, 0) + + @mock.patch("posthog.client.flags") + def test_mixed_targeting_group_without_groups_skips_to_person(self, patch_flags): + client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) + client.feature_flags = [ + { + "id": 1, + "key": "mixed-flag", + "active": True, + "filters": { + "aggregation_group_type_index": None, + "groups": [ + { + "aggregation_group_type_index": 0, + "properties": [ + {"key": "plan", "value": "enterprise", "operator": "exact", "type": "group", "group_type_index": 0} + ], + "rollout_percentage": 100, + }, + { + "aggregation_group_type_index": None, + "properties": [ + {"key": "email", "value": "test@example.com", "operator": "exact", "type": "person"} + ], + "rollout_percentage": 100, + }, + ], + }, + } + ] + client.group_type_mapping = {"0": "company"} + + # No groups passed — group condition skips, person condition matches + result = client.get_feature_flag( + "mixed-flag", + "user-123", + person_properties={"email": "test@example.com"}, + ) + self.assertTrue(result) + self.assertEqual(patch_flags.call_count, 0) + + @mock.patch("posthog.client.flags") + def test_mixed_targeting_only_group_conditions_no_groups_passed(self, patch_flags): + patch_flags.return_value = {"featureFlags": {"mixed-flag": "from-api"}} + client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) + client.feature_flags = [ + { + "id": 1, + "key": "mixed-flag", + "active": True, + "filters": { + "aggregation_group_type_index": None, + "groups": [ + { + "aggregation_group_type_index": 0, + "properties": [], + "rollout_percentage": 100, + }, + ], + }, + } + ] + client.group_type_mapping = {"0": "company"} + + # No groups passed, no person condition — all conditions skip, returns False + result = client.get_feature_flag( + "mixed-flag", + "user-123", + ) + self.assertFalse(result) + + @mock.patch("posthog.client.flags") + def test_mixed_targeting_rollout_uses_correct_bucketing(self, patch_flags): + client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) + client.feature_flags = [ + { + "id": 1, + "key": "mixed-flag", + "active": True, + "filters": { + "aggregation_group_type_index": None, + "groups": [ + { + "aggregation_group_type_index": 0, + "properties": [], + "rollout_percentage": 100, + }, + { + "aggregation_group_type_index": None, + "properties": [], + "rollout_percentage": 0, + }, + ], + }, + } + ] + client.group_type_mapping = {"0": "company"} + + # Group condition at 100% matches, person condition at 0% doesn't matter + result = client.get_feature_flag( + "mixed-flag", + "user-123", + groups={"company": "acme"}, + group_properties={"company": {}}, + ) + 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 aabb00f0bce9f60149f323e86faaff5b79d9aeda Mon Sep 17 00:00:00 2001 From: Patricio Date: Fri, 17 Apr 2026 17:53:26 -0300 Subject: [PATCH 2/8] chore: add sampo changeset --- .sampo/changesets/mixed-targeting-local-eval.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .sampo/changesets/mixed-targeting-local-eval.md diff --git a/.sampo/changesets/mixed-targeting-local-eval.md b/.sampo/changesets/mixed-targeting-local-eval.md new file mode 100644 index 00000000..375c91f3 --- /dev/null +++ b/.sampo/changesets/mixed-targeting-local-eval.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: patch +--- + +Support mixed user+group targeting in local flag evaluation. Flags with per-condition `aggregation_group_type_index` now resolve properties and bucketing per condition instead of using the flag-level aggregation only. From 738cfcd19d15cf442557a4b07bd4091c419f01f3 Mon Sep 17 00:00:00 2001 From: Patricio Date: Fri, 17 Apr 2026 17:54:03 -0300 Subject: [PATCH 3/8] chore: ruff format --- posthog/test/test_feature_flags.py | 60 ++++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index f4460fa4..71e2a886 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -3720,14 +3720,25 @@ def test_mixed_targeting_person_condition_matches(self, patch_flags): { "aggregation_group_type_index": 0, "properties": [ - {"key": "plan", "value": "enterprise", "operator": "exact", "type": "group", "group_type_index": 0} + { + "key": "plan", + "value": "enterprise", + "operator": "exact", + "type": "group", + "group_type_index": 0, + } ], "rollout_percentage": 100, }, { "aggregation_group_type_index": None, "properties": [ - {"key": "email", "value": "test@example.com", "operator": "exact", "type": "person"} + { + "key": "email", + "value": "test@example.com", + "operator": "exact", + "type": "person", + } ], "rollout_percentage": 100, }, @@ -3759,14 +3770,25 @@ def test_mixed_targeting_group_condition_matches(self, patch_flags): { "aggregation_group_type_index": 0, "properties": [ - {"key": "plan", "value": "enterprise", "operator": "exact", "type": "group", "group_type_index": 0} + { + "key": "plan", + "value": "enterprise", + "operator": "exact", + "type": "group", + "group_type_index": 0, + } ], "rollout_percentage": 100, }, { "aggregation_group_type_index": None, "properties": [ - {"key": "email", "value": "test@example.com", "operator": "exact", "type": "person"} + { + "key": "email", + "value": "test@example.com", + "operator": "exact", + "type": "person", + } ], "rollout_percentage": 100, }, @@ -3799,14 +3821,25 @@ def test_mixed_targeting_no_match_returns_false(self, patch_flags): { "aggregation_group_type_index": 0, "properties": [ - {"key": "plan", "value": "enterprise", "operator": "exact", "type": "group", "group_type_index": 0} + { + "key": "plan", + "value": "enterprise", + "operator": "exact", + "type": "group", + "group_type_index": 0, + } ], "rollout_percentage": 100, }, { "aggregation_group_type_index": None, "properties": [ - {"key": "email", "value": "test@example.com", "operator": "exact", "type": "person"} + { + "key": "email", + "value": "test@example.com", + "operator": "exact", + "type": "person", + } ], "rollout_percentage": 100, }, @@ -3840,14 +3873,25 @@ def test_mixed_targeting_group_without_groups_skips_to_person(self, patch_flags) { "aggregation_group_type_index": 0, "properties": [ - {"key": "plan", "value": "enterprise", "operator": "exact", "type": "group", "group_type_index": 0} + { + "key": "plan", + "value": "enterprise", + "operator": "exact", + "type": "group", + "group_type_index": 0, + } ], "rollout_percentage": 100, }, { "aggregation_group_type_index": None, "properties": [ - {"key": "email", "value": "test@example.com", "operator": "exact", "type": "person"} + { + "key": "email", + "value": "test@example.com", + "operator": "exact", + "type": "person", + } ], "rollout_percentage": 100, }, From 64dc0198dade0011774c9cc9b2b1d3f1a9db7f45 Mon Sep 17 00:00:00 2001 From: Patricio Date: Fri, 17 Apr 2026 17:59:06 -0300 Subject: [PATCH 4/8] fix(flags): remove redundant guard variable --- posthog/feature_flags.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index 4d49e271..103a2cc1 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -323,12 +323,11 @@ def match_feature_flag_properties( # Per-condition aggregation overrides only when the condition explicitly # sets its own aggregation_group_type_index (mixed targeting). # When absent, use the properties/bucketing already resolved by the caller. - has_condition_aggregation = "aggregation_group_type_index" in condition condition_aggregation = condition.get( "aggregation_group_type_index", flag_aggregation ) - if has_condition_aggregation and condition_aggregation != flag_aggregation: + if condition_aggregation != flag_aggregation: if condition_aggregation is not None: group_name = group_type_mapping.get(str(condition_aggregation)) if not group_name or group_name not in groups: From 7911ed0a2a62e7ebd0a845aa8ec805d3cb131148 Mon Sep 17 00:00:00 2001 From: Patricio Date: Fri, 17 Apr 2026 18:01:25 -0300 Subject: [PATCH 5/8] chore: remove duplicate test --- posthog/test/test_feature_flags.py | 52 +----------------------------- 1 file changed, 1 insertion(+), 51 deletions(-) diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 71e2a886..7a719248 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -3708,6 +3708,7 @@ def test_group_flag_dependency_ignores_device_id_bucketing_identifier( @mock.patch("posthog.client.flags") def test_mixed_targeting_person_condition_matches(self, patch_flags): + # No groups passed — group condition skips, person condition matches client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) client.feature_flags = [ { @@ -3859,57 +3860,6 @@ def test_mixed_targeting_no_match_returns_false(self, patch_flags): self.assertFalse(result) self.assertEqual(patch_flags.call_count, 0) - @mock.patch("posthog.client.flags") - def test_mixed_targeting_group_without_groups_skips_to_person(self, patch_flags): - client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) - client.feature_flags = [ - { - "id": 1, - "key": "mixed-flag", - "active": True, - "filters": { - "aggregation_group_type_index": None, - "groups": [ - { - "aggregation_group_type_index": 0, - "properties": [ - { - "key": "plan", - "value": "enterprise", - "operator": "exact", - "type": "group", - "group_type_index": 0, - } - ], - "rollout_percentage": 100, - }, - { - "aggregation_group_type_index": None, - "properties": [ - { - "key": "email", - "value": "test@example.com", - "operator": "exact", - "type": "person", - } - ], - "rollout_percentage": 100, - }, - ], - }, - } - ] - client.group_type_mapping = {"0": "company"} - - # No groups passed — group condition skips, person condition matches - result = client.get_feature_flag( - "mixed-flag", - "user-123", - person_properties={"email": "test@example.com"}, - ) - self.assertTrue(result) - self.assertEqual(patch_flags.call_count, 0) - @mock.patch("posthog.client.flags") def test_mixed_targeting_only_group_conditions_no_groups_passed(self, patch_flags): patch_flags.return_value = {"featureFlags": {"mixed-flag": "from-api"}} From d0f3c4935e8cc412fd90317232b173db02b0d8ff Mon Sep 17 00:00:00 2001 From: Patricio Date: Fri, 17 Apr 2026 18:02:21 -0300 Subject: [PATCH 6/8] chore: remove dead mock setup, add call count assertion --- posthog/test/test_feature_flags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 7a719248..ae23f683 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -3862,7 +3862,6 @@ def test_mixed_targeting_no_match_returns_false(self, patch_flags): @mock.patch("posthog.client.flags") def test_mixed_targeting_only_group_conditions_no_groups_passed(self, patch_flags): - patch_flags.return_value = {"featureFlags": {"mixed-flag": "from-api"}} client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) client.feature_flags = [ { @@ -3889,6 +3888,7 @@ def test_mixed_targeting_only_group_conditions_no_groups_passed(self, patch_flag "user-123", ) self.assertFalse(result) + self.assertEqual(patch_flags.call_count, 0) @mock.patch("posthog.client.flags") def test_mixed_targeting_rollout_uses_correct_bucketing(self, patch_flags): From c19f39b4f2baf4b6cf31f5126309c307b964dc07 Mon Sep 17 00:00:00 2001 From: Patricio Date: Fri, 17 Apr 2026 18:04:54 -0300 Subject: [PATCH 7/8] chore: parameterize mixed targeting tests --- posthog/test/test_feature_flags.py | 199 +++++++++-------------------- 1 file changed, 59 insertions(+), 140 deletions(-) diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index ae23f683..0a8269a5 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -4,6 +4,7 @@ from unittest import mock from dateutil import parser, tz from freezegun import freeze_time +from parameterized import parameterized from posthog.client import Client from posthog.feature_flags import ( @@ -3706,158 +3707,76 @@ def test_group_flag_dependency_ignores_device_id_bucketing_identifier( self.assertTrue(result) self.assertEqual(patch_flags.call_count, 0) - @mock.patch("posthog.client.flags") - def test_mixed_targeting_person_condition_matches(self, patch_flags): - # No groups passed — group condition skips, person condition matches - client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) - client.feature_flags = [ - { - "id": 1, - "key": "mixed-flag", - "active": True, - "filters": { - "aggregation_group_type_index": None, - "groups": [ - { - "aggregation_group_type_index": 0, - "properties": [ - { - "key": "plan", - "value": "enterprise", - "operator": "exact", - "type": "group", - "group_type_index": 0, - } - ], - "rollout_percentage": 100, - }, + MIXED_FLAG = { + "id": 1, + "key": "mixed-flag", + "active": True, + "filters": { + "aggregation_group_type_index": None, + "groups": [ + { + "aggregation_group_type_index": 0, + "properties": [ { - "aggregation_group_type_index": None, - "properties": [ - { - "key": "email", - "value": "test@example.com", - "operator": "exact", - "type": "person", - } - ], - "rollout_percentage": 100, - }, + "key": "plan", + "value": "enterprise", + "operator": "exact", + "type": "group", + "group_type_index": 0, + } ], + "rollout_percentage": 100, }, - } - ] - client.group_type_mapping = {"0": "company"} - - result = client.get_feature_flag( - "mixed-flag", - "user-123", - person_properties={"email": "test@example.com"}, - ) - self.assertTrue(result) - self.assertEqual(patch_flags.call_count, 0) - - @mock.patch("posthog.client.flags") - def test_mixed_targeting_group_condition_matches(self, patch_flags): - client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) - client.feature_flags = [ - { - "id": 1, - "key": "mixed-flag", - "active": True, - "filters": { + { "aggregation_group_type_index": None, - "groups": [ + "properties": [ { - "aggregation_group_type_index": 0, - "properties": [ - { - "key": "plan", - "value": "enterprise", - "operator": "exact", - "type": "group", - "group_type_index": 0, - } - ], - "rollout_percentage": 100, - }, - { - "aggregation_group_type_index": None, - "properties": [ - { - "key": "email", - "value": "test@example.com", - "operator": "exact", - "type": "person", - } - ], - "rollout_percentage": 100, - }, + "key": "email", + "value": "test@example.com", + "operator": "exact", + "type": "person", + } ], + "rollout_percentage": 100, }, - } + ], + }, + } + + @parameterized.expand( + [ + ( + "person_condition_matches", + {"person_properties": {"email": "test@example.com"}}, + True, + ), + ( + "group_condition_matches", + { + "groups": {"company": "acme"}, + "group_properties": {"company": {"plan": "enterprise"}}, + }, + True, + ), + ( + "no_match", + { + "person_properties": {"email": "wrong@example.com"}, + "groups": {"company": "acme"}, + "group_properties": {"company": {"plan": "free"}}, + }, + False, + ), ] - client.group_type_mapping = {"0": "company"} - - result = client.get_feature_flag( - "mixed-flag", - "user-123", - groups={"company": "acme"}, - group_properties={"company": {"plan": "enterprise"}}, - ) - self.assertTrue(result) - self.assertEqual(patch_flags.call_count, 0) - + ) @mock.patch("posthog.client.flags") - def test_mixed_targeting_no_match_returns_false(self, patch_flags): + def test_mixed_targeting(self, _name, call_kwargs, expected, patch_flags): client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY) - client.feature_flags = [ - { - "id": 1, - "key": "mixed-flag", - "active": True, - "filters": { - "aggregation_group_type_index": None, - "groups": [ - { - "aggregation_group_type_index": 0, - "properties": [ - { - "key": "plan", - "value": "enterprise", - "operator": "exact", - "type": "group", - "group_type_index": 0, - } - ], - "rollout_percentage": 100, - }, - { - "aggregation_group_type_index": None, - "properties": [ - { - "key": "email", - "value": "test@example.com", - "operator": "exact", - "type": "person", - } - ], - "rollout_percentage": 100, - }, - ], - }, - } - ] + client.feature_flags = [self.MIXED_FLAG] client.group_type_mapping = {"0": "company"} - result = client.get_feature_flag( - "mixed-flag", - "user-123", - person_properties={"email": "wrong@example.com"}, - groups={"company": "acme"}, - group_properties={"company": {"plan": "free"}}, - ) - self.assertFalse(result) + result = client.get_feature_flag("mixed-flag", "user-123", **call_kwargs) + self.assertEqual(bool(result), expected) self.assertEqual(patch_flags.call_count, 0) @mock.patch("posthog.client.flags") From ccabb23b701b84fa5c997f3f0f293051c3942688 Mon Sep 17 00:00:00 2001 From: Patricio Date: Fri, 24 Apr 2026 11:07:26 -0300 Subject: [PATCH 8/8] fix(flags): address review feedback --- .sampo/changesets/mixed-targeting-local-eval.md | 2 +- posthog/feature_flags.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.sampo/changesets/mixed-targeting-local-eval.md b/.sampo/changesets/mixed-targeting-local-eval.md index 375c91f3..4a78a23d 100644 --- a/.sampo/changesets/mixed-targeting-local-eval.md +++ b/.sampo/changesets/mixed-targeting-local-eval.md @@ -2,4 +2,4 @@ pypi/posthog: patch --- -Support mixed user+group targeting in local flag evaluation. Flags with per-condition `aggregation_group_type_index` now resolve properties and bucketing per condition instead of using the flag-level aggregation only. +Support mixed user+group targeting in local flag evaluation. diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index 103a2cc1..edfdbef9 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -327,10 +327,17 @@ def match_feature_flag_properties( "aggregation_group_type_index", flag_aggregation ) + # Mixed-override path: condition-level aggregation differs from flag-level. + # This assumes flag-level aggregation is None for mixed flags. if condition_aggregation != flag_aggregation: if condition_aggregation is not None: group_name = group_type_mapping.get(str(condition_aggregation)) if not group_name or group_name not in groups: + log.debug( + "Skipping group condition for flag '%s': group type index %s not available", + flag.get("key", ""), + condition_aggregation, + ) continue if group_name not in group_properties: is_inconclusive = True