Skip to content
Merged
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
13 changes: 11 additions & 2 deletions src/PostHog/Api/LocalEvaluationApiResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,14 @@ internal record FeatureFlagGroup
[JsonPropertyName("rollout_percentage")]
public int? RolloutPercentage { get; init; } = 100;

/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("aggregation_group_type_index")]
public int? AggregationGroupTypeIndex { get; init; }

/// <summary>
/// Compares this instance to another <see cref="FeatureFlagGroup"/> for equality.
/// </summary>
Expand All @@ -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;
}

/// <summary>
/// Serves as the default hash function.
/// </summary>
/// <returns>A hash code for the current object.</returns>
public override int GetHashCode() => HashCode.Combine(Properties, Variant, RolloutPercentage);
public override int GetHashCode() => HashCode.Combine(Properties, Variant, RolloutPercentage, AggregationGroupTypeIndex);
}

/// <summary>
Expand Down
33 changes: 31 additions & 2 deletions src/PostHog/Features/LocalEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -262,16 +262,45 @@ StringOrValue<bool> MatchFeatureFlagProperties(
{
var filters = flag.Filters;
var flagConditions = filters?.Groups ?? [];
var flagAggregation = filters?.AggregationGroupTypeIndex;
var isInconclusive = false;
var flagVariants = filters?.Multivariate?.Variants ?? [];

foreach (var condition in flagConditions)
{
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;
}
Comment thread
patricio-posthog marked this conversation as resolved.

// 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;
}
Expand All @@ -280,7 +309,7 @@ StringOrValue<bool> 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<bool>(variant)
Expand Down
199 changes: 199 additions & 0 deletions tests/UnitTests/Features/LocalEvaluatorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, string> { { "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<string, string> { { "0", "company" } }
};
}

public static IEnumerable<object?[]> MixedFlagCases =>
[
// person condition matches when no groups passed
["user-1", null, new Dictionary<string, object?> { ["email"] = "test@example.com" }, true],
// group condition matches when group props match
[
"user-2",
new GroupCollection { new Group("company", "acme", new Dictionary<string, object?> { ["plan"] = "enterprise" }) },
new Dictionary<string, object?> { ["email"] = "nope@example.com" },
true
],
// no match when both person and group fail
[
"user-3",
new GroupCollection { new Group("company", "acme", new Dictionary<string, object?> { ["plan"] = "free" }) },
new Dictionary<string, object?> { ["email"] = "nope@example.com" },
false
],
];

[Theory]
[MemberData(nameof(MixedFlagCases))]
public void EvaluatesMixedFlagAcrossPersonAndGroupConditions(
string distinctId,
GroupCollection? groups,
Dictionary<string, object?>? 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", <key>)` 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<string, string> { { "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(
Expand Down
Loading