Skip to content

Commit fe7e453

Browse files
feat(flags): support mixed targeting in local evaluation (#138)
1 parent 69582c2 commit fe7e453

3 files changed

Lines changed: 170 additions & 5 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'posthog-ruby': minor
3+
---
4+
5+
feat(flags): support mixed targeting in local evaluation

lib/posthog/feature_flags.rb

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -892,7 +892,8 @@ def _compute_flag_locally(flag, distinct_id, groups = {}, person_properties = {}
892892

893893
aggregation_group_type_index = flag_filters[:aggregation_group_type_index]
894894
if aggregation_group_type_index.nil?
895-
return match_feature_flag_properties(flag, distinct_id, person_properties, evaluation_cache, @cohorts)
895+
return match_feature_flag_properties(flag, distinct_id, person_properties, evaluation_cache, @cohorts,
896+
groups: groups, group_properties: group_properties)
896897
end
897898

898899
group_name = @group_type_mapping[aggregation_group_type_index.to_s.to_sym]
@@ -916,7 +917,7 @@ def _compute_flag_locally(flag, distinct_id, groups = {}, person_properties = {}
916917

917918
focused_group_properties = group_properties[group_name_symbol]
918919
match_feature_flag_properties(flag, groups[group_name_symbol], focused_group_properties, evaluation_cache,
919-
@cohorts)
920+
@cohorts, groups: groups, group_properties: group_properties)
920921
end
921922

922923
def _compute_flag_payload_locally(key, match_value)
@@ -931,23 +932,57 @@ def _compute_flag_payload_locally(key, match_value)
931932
response
932933
end
933934

934-
def match_feature_flag_properties(flag, distinct_id, properties, evaluation_cache, cohort_properties = {})
935+
def match_feature_flag_properties(flag, distinct_id, properties, evaluation_cache, cohort_properties = {},
936+
groups: {}, group_properties: {})
935937
flag_filters = flag[:filters] || {}
936938

937939
flag_conditions = flag_filters[:groups] || []
940+
flag_aggregation = flag_filters[:aggregation_group_type_index]
938941
is_inconclusive = false
939942
result = nil
940943

941944
# NOTE: This NEEDS to be `each` because `each_key` breaks
942945
flag_conditions.each do |condition|
943-
if condition_match(flag, distinct_id, condition, properties, evaluation_cache, cohort_properties)
946+
# Per-condition aggregation overrides only when the condition explicitly
947+
# sets its own aggregation_group_type_index (mixed targeting). When absent,
948+
# use the properties/bucketing already resolved by the caller.
949+
condition_aggregation = condition.fetch(:aggregation_group_type_index, flag_aggregation)
950+
951+
effective_properties = properties
952+
effective_bucketing = distinct_id
953+
954+
# Mixed-override path: condition-level aggregation differs from flag-level.
955+
# This assumes flag-level aggregation is nil for mixed flags.
956+
if condition_aggregation != flag_aggregation
957+
if condition_aggregation.nil?
958+
# Person condition under a mixed flag — caller already passed person props/bucketing.
959+
else
960+
group_name = @group_type_mapping[condition_aggregation.to_s.to_sym]
961+
if group_name.nil? || !groups.key?(group_name.to_sym)
962+
logger.debug do
963+
"[FEATURE FLAGS] Skipping group condition for flag '#{flag[:key]}': " \
964+
"group type index #{condition_aggregation} not available"
965+
end
966+
next
967+
end
968+
unless group_properties.key?(group_name.to_sym)
969+
is_inconclusive = true
970+
next
971+
end
972+
effective_properties = group_properties[group_name.to_sym]
973+
effective_bucketing = groups[group_name.to_sym]
974+
end
975+
end
976+
977+
if condition_match(flag, effective_bucketing, condition, effective_properties, evaluation_cache,
978+
cohort_properties)
944979
variant_override = condition[:variant]
945980
flag_multivariate = flag_filters[:multivariate] || {}
946981
flag_variants = flag_multivariate[:variants] || []
947982
variant = if flag_variants.map { |variant| variant[:key] }.include?(condition[:variant])
948983
variant_override
949984
else
950-
get_matching_variant(flag, distinct_id)
985+
get_matching_variant(flag, effective_bucketing)
951986
end
952987
result = variant || true
953988
break

spec/posthog/feature_flag_spec.rb

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,6 +1164,131 @@ module PostHog
11641164

11651165
assert_not_requested :post, flags_endpoint
11661166
end
1167+
1168+
context 'mixed targeting' do
1169+
mixed_flag = {
1170+
'id' => 1,
1171+
'name' => 'Mixed Flag',
1172+
'key' => 'mixed-flag',
1173+
'active' => true,
1174+
'filters' => {
1175+
'aggregation_group_type_index' => nil,
1176+
'groups' => [
1177+
{
1178+
'aggregation_group_type_index' => 0,
1179+
'properties' => [{
1180+
'key' => 'plan', 'operator' => 'exact', 'value' => ['enterprise'],
1181+
'type' => 'group', 'group_type_index' => 0
1182+
}],
1183+
'rollout_percentage' => 100
1184+
},
1185+
{
1186+
'aggregation_group_type_index' => nil,
1187+
'properties' => [{
1188+
'key' => 'email', 'operator' => 'exact', 'value' => ['test@example.com'],
1189+
'type' => 'person'
1190+
}],
1191+
'rollout_percentage' => 100
1192+
}
1193+
]
1194+
}
1195+
}
1196+
mixed_flag_response = { 'flags' => [mixed_flag], 'group_type_mapping' => { '0' => 'company' } }
1197+
1198+
only_group_flag = {
1199+
'id' => 2,
1200+
'name' => 'Only Group Flag',
1201+
'key' => 'only-group-flag',
1202+
'active' => true,
1203+
'filters' => {
1204+
'aggregation_group_type_index' => nil,
1205+
'groups' => [
1206+
{
1207+
'aggregation_group_type_index' => 0,
1208+
'properties' => [{
1209+
'key' => 'plan', 'operator' => 'exact', 'value' => ['enterprise'],
1210+
'type' => 'group', 'group_type_index' => 0
1211+
}],
1212+
'rollout_percentage' => 100
1213+
}
1214+
]
1215+
}
1216+
}
1217+
only_group_response = { 'flags' => [only_group_flag], 'group_type_mapping' => { '0' => 'company' } }
1218+
1219+
[
1220+
{ name: 'person condition matches when no groups passed',
1221+
flag_key: 'mixed-flag', response: mixed_flag_response,
1222+
opts: { person_properties: { 'email' => 'test@example.com' } },
1223+
expected: true },
1224+
{ name: 'group condition matches when group props match',
1225+
flag_key: 'mixed-flag', response: mixed_flag_response,
1226+
opts: { groups: { 'company' => 'acme' },
1227+
group_properties: { 'company' => { 'plan' => 'enterprise' } },
1228+
person_properties: { 'email' => 'nope@example.com' } },
1229+
expected: true },
1230+
{ name: 'no match when both person and group fail',
1231+
flag_key: 'mixed-flag', response: mixed_flag_response,
1232+
opts: { groups: { 'company' => 'acme' },
1233+
group_properties: { 'company' => { 'plan' => 'free' } },
1234+
person_properties: { 'email' => 'nope@example.com' } },
1235+
expected: false },
1236+
{ name: 'only group conditions, no groups passed: returns false without /flags fallback',
1237+
flag_key: 'only-group-flag', response: only_group_response,
1238+
opts: {},
1239+
expected: false }
1240+
].each do |tc|
1241+
it tc[:name] do
1242+
stub_request(
1243+
:get,
1244+
'https://us.i.posthog.com/flags/definitions?token=testsecret&send_cohorts=true'
1245+
).to_return(status: 200, body: tc[:response].to_json)
1246+
# Server fallback would return a different value if we ever reach it.
1247+
stub_request(:post, flags_endpoint)
1248+
.to_return(status: 200, body: { 'featureFlags' => { tc[:flag_key] => 'server-fallback' } }.to_json)
1249+
1250+
c = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true)
1251+
1252+
expect(c.get_feature_flag(tc[:flag_key], 'test-distinct-id', **tc[:opts])).to eq(tc[:expected])
1253+
assert_not_requested :post, flags_endpoint
1254+
end
1255+
end
1256+
1257+
it 'rollout uses group bucketing for group conditions and resolves locally' do
1258+
rollout_flag = {
1259+
'id' => 3,
1260+
'name' => 'Rollout Flag',
1261+
'key' => 'rollout-flag',
1262+
'active' => true,
1263+
'filters' => {
1264+
'aggregation_group_type_index' => nil,
1265+
'groups' => [
1266+
{
1267+
'aggregation_group_type_index' => 0,
1268+
'properties' => [],
1269+
'rollout_percentage' => 100
1270+
}
1271+
]
1272+
}
1273+
}
1274+
response = { 'flags' => [rollout_flag], 'group_type_mapping' => { '0' => 'company' } }
1275+
1276+
stub_request(
1277+
:get,
1278+
'https://us.i.posthog.com/flags/definitions?token=testsecret&send_cohorts=true'
1279+
).to_return(status: 200, body: response.to_json)
1280+
stub_request(:post, flags_endpoint).to_return(status: 400)
1281+
1282+
c = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true)
1283+
1284+
expect(c.get_feature_flag(
1285+
'rollout-flag', 'any-distinct-id',
1286+
groups: { 'company' => 'acme' },
1287+
group_properties: { 'company' => {} }
1288+
)).to eq(true)
1289+
assert_not_requested :post, flags_endpoint
1290+
end
1291+
end
11671292
end
11681293

11691294
describe 'property matching' do

0 commit comments

Comments
 (0)