diff --git a/.changeset/mixed-targeting-local-eval.md b/.changeset/mixed-targeting-local-eval.md new file mode 100644 index 0000000..64653d9 --- /dev/null +++ b/.changeset/mixed-targeting-local-eval.md @@ -0,0 +1,5 @@ +--- +'posthog-ruby': minor +--- + +feat(flags): support mixed targeting in local evaluation diff --git a/lib/posthog/feature_flags.rb b/lib/posthog/feature_flags.rb index 36f21da..2d29357 100644 --- a/lib/posthog/feature_flags.rb +++ b/lib/posthog/feature_flags.rb @@ -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] @@ -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) @@ -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 diff --git a/spec/posthog/feature_flag_spec.rb b/spec/posthog/feature_flag_spec.rb index 981b1ab..d5b54e2 100644 --- a/spec/posthog/feature_flag_spec.rb +++ b/spec/posthog/feature_flag_spec.rb @@ -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