Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mixed-targeting-local-eval.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-ruby': minor
---

feat(flags): support mixed targeting in local evaluation
45 changes: 40 additions & 5 deletions lib/posthog/feature_flags.rb
Original file line number Diff line number Diff line change
Expand Up @@ -886,7 +886,8 @@ def _compute_flag_locally(flag, distinct_id, groups = {}, person_properties = {}

aggregation_group_type_index = flag_filters[:aggregation_group_type_index]
if aggregation_group_type_index.nil?
return match_feature_flag_properties(flag, distinct_id, person_properties, evaluation_cache, @cohorts)
return match_feature_flag_properties(flag, distinct_id, person_properties, evaluation_cache, @cohorts,
groups: groups, group_properties: group_properties)
end

group_name = @group_type_mapping[aggregation_group_type_index.to_s.to_sym]
Expand All @@ -910,7 +911,7 @@ def _compute_flag_locally(flag, distinct_id, groups = {}, person_properties = {}

focused_group_properties = group_properties[group_name_symbol]
match_feature_flag_properties(flag, groups[group_name_symbol], focused_group_properties, evaluation_cache,
@cohorts)
@cohorts, groups: groups, group_properties: group_properties)
end

def _compute_flag_payload_locally(key, match_value)
Expand All @@ -925,23 +926,57 @@ def _compute_flag_payload_locally(key, match_value)
response
end

def match_feature_flag_properties(flag, distinct_id, properties, evaluation_cache, cohort_properties = {})
def match_feature_flag_properties(flag, distinct_id, properties, evaluation_cache, cohort_properties = {},
groups: {}, group_properties: {})
flag_filters = flag[:filters] || {}

flag_conditions = flag_filters[:groups] || []
flag_aggregation = flag_filters[:aggregation_group_type_index]
is_inconclusive = false
result = nil

# NOTE: This NEEDS to be `each` because `each_key` breaks
flag_conditions.each do |condition|
if condition_match(flag, distinct_id, condition, properties, evaluation_cache, cohort_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.
condition_aggregation = condition.fetch(:aggregation_group_type_index, flag_aggregation)

effective_properties = properties
effective_bucketing = distinct_id

# Mixed-override path: condition-level aggregation differs from flag-level.
# This assumes flag-level aggregation is nil for mixed flags.
if condition_aggregation != flag_aggregation
if condition_aggregation.nil?
# Person condition under a mixed flag — caller already passed person props/bucketing.
else
group_name = @group_type_mapping[condition_aggregation.to_s.to_sym]
if group_name.nil? || !groups.key?(group_name.to_sym)
logger.debug do
"[FEATURE FLAGS] Skipping group condition for flag '#{flag[:key]}': " \
"group type index #{condition_aggregation} not available"
end
next
end
unless group_properties.key?(group_name.to_sym)
is_inconclusive = true
next
end
effective_properties = group_properties[group_name.to_sym]
effective_bucketing = groups[group_name.to_sym]
end
end

if condition_match(flag, effective_bucketing, condition, effective_properties, evaluation_cache,
cohort_properties)
variant_override = condition[:variant]
flag_multivariate = flag_filters[:multivariate] || {}
flag_variants = flag_multivariate[:variants] || []
variant = if flag_variants.map { |variant| variant[:key] }.include?(condition[:variant])
variant_override
else
get_matching_variant(flag, distinct_id)
get_matching_variant(flag, effective_bucketing)
end
result = variant || true
break
Expand Down
125 changes: 125 additions & 0 deletions spec/posthog/feature_flag_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1164,6 +1164,131 @@ module PostHog

assert_not_requested :post, flags_endpoint
end

context 'mixed targeting' do
mixed_flag = {
'id' => 1,
'name' => 'Mixed Flag',
'key' => 'mixed-flag',
'active' => true,
'filters' => {
'aggregation_group_type_index' => nil,
'groups' => [
{
'aggregation_group_type_index' => 0,
'properties' => [{
'key' => 'plan', 'operator' => 'exact', 'value' => ['enterprise'],
'type' => 'group', 'group_type_index' => 0
}],
'rollout_percentage' => 100
},
{
'aggregation_group_type_index' => nil,
'properties' => [{
'key' => 'email', 'operator' => 'exact', 'value' => ['test@example.com'],
'type' => 'person'
}],
'rollout_percentage' => 100
}
]
}
}
mixed_flag_response = { 'flags' => [mixed_flag], 'group_type_mapping' => { '0' => 'company' } }

only_group_flag = {
'id' => 2,
'name' => 'Only Group Flag',
'key' => 'only-group-flag',
'active' => true,
'filters' => {
'aggregation_group_type_index' => nil,
'groups' => [
{
'aggregation_group_type_index' => 0,
'properties' => [{
'key' => 'plan', 'operator' => 'exact', 'value' => ['enterprise'],
'type' => 'group', 'group_type_index' => 0
}],
'rollout_percentage' => 100
}
]
}
}
only_group_response = { 'flags' => [only_group_flag], 'group_type_mapping' => { '0' => 'company' } }

[
{ name: 'person condition matches when no groups passed',
flag_key: 'mixed-flag', response: mixed_flag_response,
opts: { person_properties: { 'email' => 'test@example.com' } },
expected: true },
{ name: 'group condition matches when group props match',
flag_key: 'mixed-flag', response: mixed_flag_response,
opts: { groups: { 'company' => 'acme' },
group_properties: { 'company' => { 'plan' => 'enterprise' } },
person_properties: { 'email' => 'nope@example.com' } },
expected: true },
{ name: 'no match when both person and group fail',
flag_key: 'mixed-flag', response: mixed_flag_response,
opts: { groups: { 'company' => 'acme' },
group_properties: { 'company' => { 'plan' => 'free' } },
person_properties: { 'email' => 'nope@example.com' } },
expected: false },
{ name: 'only group conditions, no groups passed: returns false without /flags fallback',
flag_key: 'only-group-flag', response: only_group_response,
opts: {},
expected: false }
].each do |tc|
it tc[:name] do
stub_request(
:get,
'https://us.i.posthog.com/flags/definitions?token=testsecret&send_cohorts=true'
).to_return(status: 200, body: tc[:response].to_json)
# Server fallback would return a different value if we ever reach it.
stub_request(:post, flags_endpoint)
.to_return(status: 200, body: { 'featureFlags' => { tc[:flag_key] => 'server-fallback' } }.to_json)

c = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true)

expect(c.get_feature_flag(tc[:flag_key], 'test-distinct-id', **tc[:opts])).to eq(tc[:expected])
assert_not_requested :post, flags_endpoint
end
end

it 'rollout uses group bucketing for group conditions and resolves locally' do
rollout_flag = {
'id' => 3,
'name' => 'Rollout Flag',
'key' => 'rollout-flag',
'active' => true,
'filters' => {
'aggregation_group_type_index' => nil,
'groups' => [
{
'aggregation_group_type_index' => 0,
'properties' => [],
'rollout_percentage' => 100
}
]
}
}
response = { 'flags' => [rollout_flag], 'group_type_mapping' => { '0' => 'company' } }

stub_request(
:get,
'https://us.i.posthog.com/flags/definitions?token=testsecret&send_cohorts=true'
).to_return(status: 200, body: response.to_json)
stub_request(:post, flags_endpoint).to_return(status: 400)

c = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true)

expect(c.get_feature_flag(
'rollout-flag', 'any-distinct-id',
groups: { 'company' => 'acme' },
group_properties: { 'company' => {} }
)).to eq(true)
assert_not_requested :post, flags_endpoint
end
end
end

describe 'property matching' do
Expand Down
Loading