From 145a4c1e9682295cb9bc7aa45c4d048c219c5428 Mon Sep 17 00:00:00 2001 From: Patricio Date: Tue, 28 Apr 2026 15:58:42 -0300 Subject: [PATCH 1/3] feat(flags): support mixed targeting in local evaluation --- src/PostHog/Api/LocalEvaluationApiResult.cs | 13 +- src/PostHog/Features/LocalEvaluator.cs | 32 ++- .../UnitTests/Features/LocalEvaluatorTests.cs | 206 ++++++++++++++++++ 3 files changed, 247 insertions(+), 4 deletions(-) diff --git a/src/PostHog/Api/LocalEvaluationApiResult.cs b/src/PostHog/Api/LocalEvaluationApiResult.cs index edb7225..1575ffe 100644 --- a/src/PostHog/Api/LocalEvaluationApiResult.cs +++ b/src/PostHog/Api/LocalEvaluationApiResult.cs @@ -199,6 +199,14 @@ internal record FeatureFlagGroup [JsonPropertyName("rollout_percentage")] public int? RolloutPercentage { get; init; } = 100; + /// + /// Optional per-condition aggregation override used by mixed-targeting flags. + /// When set, this condition targets the specified group type instead of the + /// flag-level aggregation. Null means person targeting under a mixed flag. + /// + [JsonPropertyName("aggregation_group_type_index")] + public int? AggregationGroupTypeIndex { get; init; } + /// /// Compares this instance to another for equality. /// @@ -219,14 +227,15 @@ public virtual bool Equals(FeatureFlagGroup? other) return ((Properties is null && other.Properties is null) || (Properties is not null && other.Properties is not null && Properties.SequenceEqual(other.Properties))) && Variant == other.Variant - && RolloutPercentage == other.RolloutPercentage; + && RolloutPercentage == other.RolloutPercentage + && AggregationGroupTypeIndex == other.AggregationGroupTypeIndex; } /// /// Serves as the default hash function. /// /// A hash code for the current object. - public override int GetHashCode() => HashCode.Combine(Properties, Variant, RolloutPercentage); + public override int GetHashCode() => HashCode.Combine(Properties, Variant, RolloutPercentage, AggregationGroupTypeIndex); } /// diff --git a/src/PostHog/Features/LocalEvaluator.cs b/src/PostHog/Features/LocalEvaluator.cs index 60564f6..fdfe84e 100644 --- a/src/PostHog/Features/LocalEvaluator.cs +++ b/src/PostHog/Features/LocalEvaluator.cs @@ -262,6 +262,7 @@ StringOrValue MatchFeatureFlagProperties( { var filters = flag.Filters; var flagConditions = filters?.Groups ?? []; + var flagAggregation = filters?.AggregationGroupTypeIndex; var isInconclusive = false; var flagVariants = filters?.Multivariate?.Variants ?? []; @@ -269,9 +270,36 @@ StringOrValue MatchFeatureFlagProperties( { try { + // Per-condition aggregation overrides only when the condition explicitly + // sets its own AggregationGroupTypeIndex (mixed targeting). When absent, + // fall back to the flag-level aggregation so existing pure person and + // pure group flags keep their original behavior. + var conditionAggregation = condition.AggregationGroupTypeIndex ?? flagAggregation; + + var effectiveProperties = properties; + var effectiveBucketingId = distinctId; + + // Mixed-override path: condition-level aggregation differs from flag-level. + // This assumes flag-level aggregation is null for mixed flags. + if (conditionAggregation != flagAggregation) + { + if (conditionAggregation.HasValue) + { + if (!_groupTypeMapping.TryGetValue(conditionAggregation.Value, out var groupType) + || groups is null + || !groups.TryGetGroup(groupType, out var group)) + { + // Skip this condition: group type unknown or not passed in. + continue; + } + effectiveProperties = group.Properties; + effectiveBucketingId = group.GroupKey; + } + } + // if any one condition resolves to True, we can short circuit and return // the matching variant - if (!IsConditionMatch(flag, distinctId, condition, properties, evaluationCache, groups)) + if (!IsConditionMatch(flag, effectiveBucketingId, condition, effectiveProperties, evaluationCache, groups)) { continue; } @@ -280,7 +308,7 @@ StringOrValue MatchFeatureFlagProperties( var variant = variantOverride is not null && flagVariants.Select(v => v.Key).Contains(variantOverride) ? variantOverride - : GetMatchingVariant(flag, distinctId); + : GetMatchingVariant(flag, effectiveBucketingId); return variant is not null ? new StringOrValue(variant) diff --git a/tests/UnitTests/Features/LocalEvaluatorTests.cs b/tests/UnitTests/Features/LocalEvaluatorTests.cs index fe594cd..4fd5e26 100644 --- a/tests/UnitTests/Features/LocalEvaluatorTests.cs +++ b/tests/UnitTests/Features/LocalEvaluatorTests.cs @@ -2,6 +2,7 @@ using System.Text.Json; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Time.Testing; +using PostHog; using PostHog.Api; using PostHog.Features; using PostHog.Json; @@ -748,6 +749,211 @@ public void ThrowsInconclusiveMatchExceptionWhenUnknownOperator() } } +public class TheMixedTargetingEvaluation +{ + static LocalEvaluationApiResult CreateMixedFlag() + { + return new LocalEvaluationApiResult + { + Flags = [ + new LocalFeatureFlag + { + Id = 1, + TeamId = 1, + Name = "Mixed Flag", + Key = "mixed-flag", + Active = true, + Filters = new FeatureFlagFilters + { + AggregationGroupTypeIndex = null, + Groups = [ + new FeatureFlagGroup + { + AggregationGroupTypeIndex = 0, + Properties = [ + new PropertyFilter + { + Type = FilterType.Group, + Key = "plan", + Value = new PropertyFilterValue("enterprise"), + Operator = ComparisonOperator.Exact, + GroupTypeIndex = 0 + } + ], + RolloutPercentage = 100 + }, + new FeatureFlagGroup + { + AggregationGroupTypeIndex = null, + Properties = [ + new PropertyFilter + { + Type = FilterType.Person, + Key = "email", + Value = new PropertyFilterValue("test@example.com"), + Operator = ComparisonOperator.Exact + } + ], + RolloutPercentage = 100 + } + ] + } + } + ], + GroupTypeMapping = new Dictionary { { "0", "company" } } + }; + } + + static LocalEvaluationApiResult CreateOnlyGroupFlag() + { + return new LocalEvaluationApiResult + { + Flags = [ + new LocalFeatureFlag + { + Id = 2, + TeamId = 1, + Name = "Only Group Flag", + Key = "only-group-flag", + Active = true, + Filters = new FeatureFlagFilters + { + AggregationGroupTypeIndex = null, + Groups = [ + new FeatureFlagGroup + { + AggregationGroupTypeIndex = 0, + Properties = [ + new PropertyFilter + { + Type = FilterType.Group, + Key = "plan", + Value = new PropertyFilterValue("enterprise"), + Operator = ComparisonOperator.Exact, + GroupTypeIndex = 0 + } + ], + RolloutPercentage = 100 + } + ] + } + } + ], + GroupTypeMapping = new Dictionary { { "0", "company" } } + }; + } + + [Fact] + public void PersonConditionMatchesWhenNoGroupsPassed() + { + var localEvaluator = new LocalEvaluator(CreateMixedFlag()); + var personProperties = new Dictionary { ["email"] = "test@example.com" }; + + var result = localEvaluator.EvaluateFeatureFlag( + key: "mixed-flag", + distinctId: "user-1", + personProperties: personProperties); + + Assert.Equal(true, result); + } + + [Fact] + public void GroupConditionMatchesWhenGroupPropsMatch() + { + var localEvaluator = new LocalEvaluator(CreateMixedFlag()); + var groups = new GroupCollection + { + new Group("company", "acme", new Dictionary { ["plan"] = "enterprise" }) + }; + var personProperties = new Dictionary { ["email"] = "nope@example.com" }; + + var result = localEvaluator.EvaluateFeatureFlag( + key: "mixed-flag", + distinctId: "user-2", + groups: groups, + personProperties: personProperties); + + Assert.Equal(true, result); + } + + [Fact] + public void NoMatchWhenBothPersonAndGroupFail() + { + var localEvaluator = new LocalEvaluator(CreateMixedFlag()); + var groups = new GroupCollection + { + new Group("company", "acme", new Dictionary { ["plan"] = "free" }) + }; + var personProperties = new Dictionary { ["email"] = "nope@example.com" }; + + var result = localEvaluator.EvaluateFeatureFlag( + key: "mixed-flag", + distinctId: "user-3", + groups: groups, + personProperties: personProperties); + + Assert.Equal(false, result); + } + + [Fact] + public void OnlyGroupConditionWithNoGroupsPassedReturnsFalseWithoutThrowing() + { + var localEvaluator = new LocalEvaluator(CreateOnlyGroupFlag()); + + var result = localEvaluator.EvaluateFeatureFlag( + key: "only-group-flag", + distinctId: "user-1"); + + // Group condition skips (no groups passed); no inconclusive raised. + Assert.Equal(false, result); + } + + [Fact] + public void RolloutUsesGroupKeyForGroupConditionsUnderMixedFlags() + { + // Mixed flag with a single group condition at 100% rollout — passing the group + // must resolve locally, proving the matcher hashes on the group key path. + var flags = new LocalEvaluationApiResult + { + Flags = [ + new LocalFeatureFlag + { + Id = 3, + TeamId = 1, + Name = "Rollout Flag", + Key = "rollout-flag", + Active = true, + Filters = new FeatureFlagFilters + { + AggregationGroupTypeIndex = null, + Groups = [ + new FeatureFlagGroup + { + AggregationGroupTypeIndex = 0, + Properties = [], + RolloutPercentage = 100 + } + ] + } + } + ], + GroupTypeMapping = new Dictionary { { "0", "company" } } + }; + var localEvaluator = new LocalEvaluator(flags); + var groups = new GroupCollection + { + new Group("company", "acme") + }; + + var result = localEvaluator.EvaluateFeatureFlag( + key: "rollout-flag", + distinctId: "any-distinct-id", + groups: groups); + + Assert.Equal(true, result); + } +} + public class TheFlagDependencyEvaluationMethod { static LocalEvaluationApiResult CreateFlagsWithDependencies( From b17456063cc2c02067fcc96e470eb1d38ce31095 Mon Sep 17 00:00:00 2001 From: Patricio Date: Wed, 29 Apr 2026 14:03:19 -0300 Subject: [PATCH 2/3] addressing phils comments --- src/PostHog/Features/LocalEvaluator.cs | 5 ++-- .../UnitTests/Features/LocalEvaluatorTests.cs | 26 ++++++++++++------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/PostHog/Features/LocalEvaluator.cs b/src/PostHog/Features/LocalEvaluator.cs index fdfe84e..b1549bd 100644 --- a/src/PostHog/Features/LocalEvaluator.cs +++ b/src/PostHog/Features/LocalEvaluator.cs @@ -279,8 +279,9 @@ StringOrValue MatchFeatureFlagProperties( var effectiveProperties = properties; var effectiveBucketingId = distinctId; - // Mixed-override path: condition-level aggregation differs from flag-level. - // This assumes flag-level aggregation is null for mixed flags. + // The condition explicitly sets its own aggregation, different from the + // flag level. Re-resolve properties and bucketing id from the condition's + // group so this condition evaluates against that group. if (conditionAggregation != flagAggregation) { if (conditionAggregation.HasValue) diff --git a/tests/UnitTests/Features/LocalEvaluatorTests.cs b/tests/UnitTests/Features/LocalEvaluatorTests.cs index 4fd5e26..5578291 100644 --- a/tests/UnitTests/Features/LocalEvaluatorTests.cs +++ b/tests/UnitTests/Features/LocalEvaluatorTests.cs @@ -908,11 +908,17 @@ public void OnlyGroupConditionWithNoGroupsPassedReturnsFalseWithoutThrowing() Assert.Equal(false, result); } - [Fact] - public void RolloutUsesGroupKeyForGroupConditionsUnderMixedFlags() + // Group keys whose hash against `Hash("rollout-flag", )` straddles the 50% bucket, + // and a distinct_id whose hash is also outside the bucket. If the matcher regressed to + // bucketing on distinct_id, both assertions below would yield false and the in-bucket + // assertion would fail. + [Theory] + [InlineData("company-7", true)] // hash ~0.118 → in bucket at 50% + [InlineData("company-2", false)] // hash ~0.803 → out of bucket at 50% + public void RolloutUsesGroupKeyForGroupConditionsUnderMixedFlags(string groupKey, bool expected) { - // Mixed flag with a single group condition at 100% rollout — passing the group - // must resolve locally, proving the matcher hashes on the group key path. + const string flagKey = "rollout-flag"; + const string distinctId = "user-0"; // Hash("rollout-flag", "user-0") ~0.788 (out at 50%) var flags = new LocalEvaluationApiResult { Flags = [ @@ -921,7 +927,7 @@ public void RolloutUsesGroupKeyForGroupConditionsUnderMixedFlags() Id = 3, TeamId = 1, Name = "Rollout Flag", - Key = "rollout-flag", + Key = flagKey, Active = true, Filters = new FeatureFlagFilters { @@ -931,7 +937,7 @@ public void RolloutUsesGroupKeyForGroupConditionsUnderMixedFlags() { AggregationGroupTypeIndex = 0, Properties = [], - RolloutPercentage = 100 + RolloutPercentage = 50 } ] } @@ -942,15 +948,15 @@ public void RolloutUsesGroupKeyForGroupConditionsUnderMixedFlags() var localEvaluator = new LocalEvaluator(flags); var groups = new GroupCollection { - new Group("company", "acme") + new Group("company", groupKey) }; var result = localEvaluator.EvaluateFeatureFlag( - key: "rollout-flag", - distinctId: "any-distinct-id", + key: flagKey, + distinctId: distinctId, groups: groups); - Assert.Equal(true, result); + Assert.Equal(expected, result); } } From 37777a47cad8266a06bfda8d8b52855196bb52a7 Mon Sep 17 00:00:00 2001 From: Patricio Date: Wed, 29 Apr 2026 14:19:52 -0300 Subject: [PATCH 3/3] addressing greptile comments --- src/PostHog/Features/LocalEvaluator.cs | 20 +++--- .../UnitTests/Features/LocalEvaluatorTests.cs | 69 ++++++++----------- 2 files changed, 38 insertions(+), 51 deletions(-) diff --git a/src/PostHog/Features/LocalEvaluator.cs b/src/PostHog/Features/LocalEvaluator.cs index b1549bd..508b247 100644 --- a/src/PostHog/Features/LocalEvaluator.cs +++ b/src/PostHog/Features/LocalEvaluator.cs @@ -282,20 +282,20 @@ StringOrValue MatchFeatureFlagProperties( // The condition explicitly sets its own aggregation, different from the // flag level. Re-resolve properties and bucketing id from the condition's // group so this condition evaluates against that group. + // conditionAggregation is non-null here: if it were null and flagAggregation + // were also null they'd be equal, and the only way conditionAggregation can + // be null at all (after the ?? above) is that case. if (conditionAggregation != flagAggregation) { - if (conditionAggregation.HasValue) + if (!_groupTypeMapping.TryGetValue(conditionAggregation!.Value, out var groupType) + || groups is null + || !groups.TryGetGroup(groupType, out var group)) { - if (!_groupTypeMapping.TryGetValue(conditionAggregation.Value, out var groupType) - || groups is null - || !groups.TryGetGroup(groupType, out var group)) - { - // Skip this condition: group type unknown or not passed in. - continue; - } - effectiveProperties = group.Properties; - effectiveBucketingId = group.GroupKey; + // Skip this condition: group type unknown or not passed in. + continue; } + effectiveProperties = group.Properties; + effectiveBucketingId = group.GroupKey; } // if any one condition resolves to True, we can short circuit and return diff --git a/tests/UnitTests/Features/LocalEvaluatorTests.cs b/tests/UnitTests/Features/LocalEvaluatorTests.cs index 5578291..758efce 100644 --- a/tests/UnitTests/Features/LocalEvaluatorTests.cs +++ b/tests/UnitTests/Features/LocalEvaluatorTests.cs @@ -843,56 +843,43 @@ static LocalEvaluationApiResult CreateOnlyGroupFlag() }; } - [Fact] - public void PersonConditionMatchesWhenNoGroupsPassed() - { - var localEvaluator = new LocalEvaluator(CreateMixedFlag()); - var personProperties = new Dictionary { ["email"] = "test@example.com" }; - - var result = localEvaluator.EvaluateFeatureFlag( - key: "mixed-flag", - distinctId: "user-1", - personProperties: personProperties); - - Assert.Equal(true, result); - } - - [Fact] - public void GroupConditionMatchesWhenGroupPropsMatch() - { - var localEvaluator = new LocalEvaluator(CreateMixedFlag()); - var groups = new GroupCollection - { - new Group("company", "acme", new Dictionary { ["plan"] = "enterprise" }) - }; - var personProperties = new Dictionary { ["email"] = "nope@example.com" }; + public static IEnumerable MixedFlagCases => + [ + // person condition matches when no groups passed + ["user-1", null, new Dictionary { ["email"] = "test@example.com" }, true], + // group condition matches when group props match + [ + "user-2", + new GroupCollection { new Group("company", "acme", new Dictionary { ["plan"] = "enterprise" }) }, + new Dictionary { ["email"] = "nope@example.com" }, + true + ], + // no match when both person and group fail + [ + "user-3", + new GroupCollection { new Group("company", "acme", new Dictionary { ["plan"] = "free" }) }, + new Dictionary { ["email"] = "nope@example.com" }, + false + ], + ]; - var result = localEvaluator.EvaluateFeatureFlag( - key: "mixed-flag", - distinctId: "user-2", - groups: groups, - personProperties: personProperties); - - Assert.Equal(true, result); - } - - [Fact] - public void NoMatchWhenBothPersonAndGroupFail() + [Theory] + [MemberData(nameof(MixedFlagCases))] + public void EvaluatesMixedFlagAcrossPersonAndGroupConditions( + string distinctId, + GroupCollection? groups, + Dictionary? personProperties, + bool expected) { var localEvaluator = new LocalEvaluator(CreateMixedFlag()); - var groups = new GroupCollection - { - new Group("company", "acme", new Dictionary { ["plan"] = "free" }) - }; - var personProperties = new Dictionary { ["email"] = "nope@example.com" }; var result = localEvaluator.EvaluateFeatureFlag( key: "mixed-flag", - distinctId: "user-3", + distinctId: distinctId, groups: groups, personProperties: personProperties); - Assert.Equal(false, result); + Assert.Equal(expected, result); } [Fact]