Skip to content

Commit e35bf94

Browse files
feat(flags): support mixed targeting in local evaluation (#188)
* feat(flags): support mixed targeting in local evaluation * addressing phils comments * addressing greptile comments
1 parent 84c027a commit e35bf94

3 files changed

Lines changed: 241 additions & 4 deletions

File tree

src/PostHog/Api/LocalEvaluationApiResult.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,14 @@ internal record FeatureFlagGroup
199199
[JsonPropertyName("rollout_percentage")]
200200
public int? RolloutPercentage { get; init; } = 100;
201201

202+
/// <summary>
203+
/// Optional per-condition aggregation override used by mixed-targeting flags.
204+
/// When set, this condition targets the specified group type instead of the
205+
/// flag-level aggregation. Null means person targeting under a mixed flag.
206+
/// </summary>
207+
[JsonPropertyName("aggregation_group_type_index")]
208+
public int? AggregationGroupTypeIndex { get; init; }
209+
202210
/// <summary>
203211
/// Compares this instance to another <see cref="FeatureFlagGroup"/> for equality.
204212
/// </summary>
@@ -219,14 +227,15 @@ public virtual bool Equals(FeatureFlagGroup? other)
219227
return ((Properties is null && other.Properties is null)
220228
|| (Properties is not null && other.Properties is not null && Properties.SequenceEqual(other.Properties)))
221229
&& Variant == other.Variant
222-
&& RolloutPercentage == other.RolloutPercentage;
230+
&& RolloutPercentage == other.RolloutPercentage
231+
&& AggregationGroupTypeIndex == other.AggregationGroupTypeIndex;
223232
}
224233

225234
/// <summary>
226235
/// Serves as the default hash function.
227236
/// </summary>
228237
/// <returns>A hash code for the current object.</returns>
229-
public override int GetHashCode() => HashCode.Combine(Properties, Variant, RolloutPercentage);
238+
public override int GetHashCode() => HashCode.Combine(Properties, Variant, RolloutPercentage, AggregationGroupTypeIndex);
230239
}
231240

232241
/// <summary>

src/PostHog/Features/LocalEvaluator.cs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,16 +262,45 @@ StringOrValue<bool> MatchFeatureFlagProperties(
262262
{
263263
var filters = flag.Filters;
264264
var flagConditions = filters?.Groups ?? [];
265+
var flagAggregation = filters?.AggregationGroupTypeIndex;
265266
var isInconclusive = false;
266267
var flagVariants = filters?.Multivariate?.Variants ?? [];
267268

268269
foreach (var condition in flagConditions)
269270
{
270271
try
271272
{
273+
// Per-condition aggregation overrides only when the condition explicitly
274+
// sets its own AggregationGroupTypeIndex (mixed targeting). When absent,
275+
// fall back to the flag-level aggregation so existing pure person and
276+
// pure group flags keep their original behavior.
277+
var conditionAggregation = condition.AggregationGroupTypeIndex ?? flagAggregation;
278+
279+
var effectiveProperties = properties;
280+
var effectiveBucketingId = distinctId;
281+
282+
// The condition explicitly sets its own aggregation, different from the
283+
// flag level. Re-resolve properties and bucketing id from the condition's
284+
// group so this condition evaluates against that group.
285+
// conditionAggregation is non-null here: if it were null and flagAggregation
286+
// were also null they'd be equal, and the only way conditionAggregation can
287+
// be null at all (after the ?? above) is that case.
288+
if (conditionAggregation != flagAggregation)
289+
{
290+
if (!_groupTypeMapping.TryGetValue(conditionAggregation!.Value, out var groupType)
291+
|| groups is null
292+
|| !groups.TryGetGroup(groupType, out var group))
293+
{
294+
// Skip this condition: group type unknown or not passed in.
295+
continue;
296+
}
297+
effectiveProperties = group.Properties;
298+
effectiveBucketingId = group.GroupKey;
299+
}
300+
272301
// if any one condition resolves to True, we can short circuit and return
273302
// the matching variant
274-
if (!IsConditionMatch(flag, distinctId, condition, properties, evaluationCache, groups))
303+
if (!IsConditionMatch(flag, effectiveBucketingId, condition, effectiveProperties, evaluationCache, groups))
275304
{
276305
continue;
277306
}
@@ -280,7 +309,7 @@ StringOrValue<bool> MatchFeatureFlagProperties(
280309
var variant = variantOverride is not null
281310
&& flagVariants.Select(v => v.Key).Contains(variantOverride)
282311
? variantOverride
283-
: GetMatchingVariant(flag, distinctId);
312+
: GetMatchingVariant(flag, effectiveBucketingId);
284313

285314
return variant is not null
286315
? new StringOrValue<bool>(variant)

tests/UnitTests/Features/LocalEvaluatorTests.cs

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Text.Json;
33
using Microsoft.Extensions.Logging.Abstractions;
44
using Microsoft.Extensions.Time.Testing;
5+
using PostHog;
56
using PostHog.Api;
67
using PostHog.Features;
78
using PostHog.Json;
@@ -748,6 +749,204 @@ public void ThrowsInconclusiveMatchExceptionWhenUnknownOperator()
748749
}
749750
}
750751

752+
public class TheMixedTargetingEvaluation
753+
{
754+
static LocalEvaluationApiResult CreateMixedFlag()
755+
{
756+
return new LocalEvaluationApiResult
757+
{
758+
Flags = [
759+
new LocalFeatureFlag
760+
{
761+
Id = 1,
762+
TeamId = 1,
763+
Name = "Mixed Flag",
764+
Key = "mixed-flag",
765+
Active = true,
766+
Filters = new FeatureFlagFilters
767+
{
768+
AggregationGroupTypeIndex = null,
769+
Groups = [
770+
new FeatureFlagGroup
771+
{
772+
AggregationGroupTypeIndex = 0,
773+
Properties = [
774+
new PropertyFilter
775+
{
776+
Type = FilterType.Group,
777+
Key = "plan",
778+
Value = new PropertyFilterValue("enterprise"),
779+
Operator = ComparisonOperator.Exact,
780+
GroupTypeIndex = 0
781+
}
782+
],
783+
RolloutPercentage = 100
784+
},
785+
new FeatureFlagGroup
786+
{
787+
AggregationGroupTypeIndex = null,
788+
Properties = [
789+
new PropertyFilter
790+
{
791+
Type = FilterType.Person,
792+
Key = "email",
793+
Value = new PropertyFilterValue("test@example.com"),
794+
Operator = ComparisonOperator.Exact
795+
}
796+
],
797+
RolloutPercentage = 100
798+
}
799+
]
800+
}
801+
}
802+
],
803+
GroupTypeMapping = new Dictionary<string, string> { { "0", "company" } }
804+
};
805+
}
806+
807+
static LocalEvaluationApiResult CreateOnlyGroupFlag()
808+
{
809+
return new LocalEvaluationApiResult
810+
{
811+
Flags = [
812+
new LocalFeatureFlag
813+
{
814+
Id = 2,
815+
TeamId = 1,
816+
Name = "Only Group Flag",
817+
Key = "only-group-flag",
818+
Active = true,
819+
Filters = new FeatureFlagFilters
820+
{
821+
AggregationGroupTypeIndex = null,
822+
Groups = [
823+
new FeatureFlagGroup
824+
{
825+
AggregationGroupTypeIndex = 0,
826+
Properties = [
827+
new PropertyFilter
828+
{
829+
Type = FilterType.Group,
830+
Key = "plan",
831+
Value = new PropertyFilterValue("enterprise"),
832+
Operator = ComparisonOperator.Exact,
833+
GroupTypeIndex = 0
834+
}
835+
],
836+
RolloutPercentage = 100
837+
}
838+
]
839+
}
840+
}
841+
],
842+
GroupTypeMapping = new Dictionary<string, string> { { "0", "company" } }
843+
};
844+
}
845+
846+
public static IEnumerable<object?[]> MixedFlagCases =>
847+
[
848+
// person condition matches when no groups passed
849+
["user-1", null, new Dictionary<string, object?> { ["email"] = "test@example.com" }, true],
850+
// group condition matches when group props match
851+
[
852+
"user-2",
853+
new GroupCollection { new Group("company", "acme", new Dictionary<string, object?> { ["plan"] = "enterprise" }) },
854+
new Dictionary<string, object?> { ["email"] = "nope@example.com" },
855+
true
856+
],
857+
// no match when both person and group fail
858+
[
859+
"user-3",
860+
new GroupCollection { new Group("company", "acme", new Dictionary<string, object?> { ["plan"] = "free" }) },
861+
new Dictionary<string, object?> { ["email"] = "nope@example.com" },
862+
false
863+
],
864+
];
865+
866+
[Theory]
867+
[MemberData(nameof(MixedFlagCases))]
868+
public void EvaluatesMixedFlagAcrossPersonAndGroupConditions(
869+
string distinctId,
870+
GroupCollection? groups,
871+
Dictionary<string, object?>? personProperties,
872+
bool expected)
873+
{
874+
var localEvaluator = new LocalEvaluator(CreateMixedFlag());
875+
876+
var result = localEvaluator.EvaluateFeatureFlag(
877+
key: "mixed-flag",
878+
distinctId: distinctId,
879+
groups: groups,
880+
personProperties: personProperties);
881+
882+
Assert.Equal(expected, result);
883+
}
884+
885+
[Fact]
886+
public void OnlyGroupConditionWithNoGroupsPassedReturnsFalseWithoutThrowing()
887+
{
888+
var localEvaluator = new LocalEvaluator(CreateOnlyGroupFlag());
889+
890+
var result = localEvaluator.EvaluateFeatureFlag(
891+
key: "only-group-flag",
892+
distinctId: "user-1");
893+
894+
// Group condition skips (no groups passed); no inconclusive raised.
895+
Assert.Equal(false, result);
896+
}
897+
898+
// Group keys whose hash against `Hash("rollout-flag", <key>)` straddles the 50% bucket,
899+
// and a distinct_id whose hash is also outside the bucket. If the matcher regressed to
900+
// bucketing on distinct_id, both assertions below would yield false and the in-bucket
901+
// assertion would fail.
902+
[Theory]
903+
[InlineData("company-7", true)] // hash ~0.118 → in bucket at 50%
904+
[InlineData("company-2", false)] // hash ~0.803 → out of bucket at 50%
905+
public void RolloutUsesGroupKeyForGroupConditionsUnderMixedFlags(string groupKey, bool expected)
906+
{
907+
const string flagKey = "rollout-flag";
908+
const string distinctId = "user-0"; // Hash("rollout-flag", "user-0") ~0.788 (out at 50%)
909+
var flags = new LocalEvaluationApiResult
910+
{
911+
Flags = [
912+
new LocalFeatureFlag
913+
{
914+
Id = 3,
915+
TeamId = 1,
916+
Name = "Rollout Flag",
917+
Key = flagKey,
918+
Active = true,
919+
Filters = new FeatureFlagFilters
920+
{
921+
AggregationGroupTypeIndex = null,
922+
Groups = [
923+
new FeatureFlagGroup
924+
{
925+
AggregationGroupTypeIndex = 0,
926+
Properties = [],
927+
RolloutPercentage = 50
928+
}
929+
]
930+
}
931+
}
932+
],
933+
GroupTypeMapping = new Dictionary<string, string> { { "0", "company" } }
934+
};
935+
var localEvaluator = new LocalEvaluator(flags);
936+
var groups = new GroupCollection
937+
{
938+
new Group("company", groupKey)
939+
};
940+
941+
var result = localEvaluator.EvaluateFeatureFlag(
942+
key: flagKey,
943+
distinctId: distinctId,
944+
groups: groups);
945+
946+
Assert.Equal(expected, result);
947+
}
948+
}
949+
751950
public class TheFlagDependencyEvaluationMethod
752951
{
753952
static LocalEvaluationApiResult CreateFlagsWithDependencies(

0 commit comments

Comments
 (0)