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..508b247 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,37 @@ 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; + + // 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 (!_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 +309,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..758efce 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,204 @@ 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" } } + }; + } + + 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 + ], + ]; + + [Theory] + [MemberData(nameof(MixedFlagCases))] + public void EvaluatesMixedFlagAcrossPersonAndGroupConditions( + string distinctId, + GroupCollection? groups, + Dictionary? personProperties, + bool expected) + { + var localEvaluator = new LocalEvaluator(CreateMixedFlag()); + + var result = localEvaluator.EvaluateFeatureFlag( + key: "mixed-flag", + distinctId: distinctId, + groups: groups, + personProperties: personProperties); + + Assert.Equal(expected, 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); + } + + // 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) + { + 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 = [ + new LocalFeatureFlag + { + Id = 3, + TeamId = 1, + Name = "Rollout Flag", + Key = flagKey, + Active = true, + Filters = new FeatureFlagFilters + { + AggregationGroupTypeIndex = null, + Groups = [ + new FeatureFlagGroup + { + AggregationGroupTypeIndex = 0, + Properties = [], + RolloutPercentage = 50 + } + ] + } + } + ], + GroupTypeMapping = new Dictionary { { "0", "company" } } + }; + var localEvaluator = new LocalEvaluator(flags); + var groups = new GroupCollection + { + new Group("company", groupKey) + }; + + var result = localEvaluator.EvaluateFeatureFlag( + key: flagKey, + distinctId: distinctId, + groups: groups); + + Assert.Equal(expected, result); + } +} + public class TheFlagDependencyEvaluationMethod { static LocalEvaluationApiResult CreateFlagsWithDependencies(