Skip to content

Commit 0c4094f

Browse files
scott-the-programmerclaudemarandaneto
authored
fix: support fractional rollout percentages in local evaluation (#292)
* fix: support fractional rollout percentages FeatureFlagGroup.RolloutPercentage was typed int?, so a flag defined with a fractional rollout such as 0.1% failed to deserialize from the local evaluation payload. PostHog now also serves whole percentages with a decimal point (100.0, 0.0), which fails the same way. Widen it to double?. The bucketing comparison in LocalEvaluator already divided by 100.0, so the loss was purely at the deserialization boundary. Covers fractional rollouts (0.1, 0.5) and the 100.0 / 0.0 / null boundaries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: add changeset for fractional rollouts --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Manoel Aranda Neto <marandaneto@gmail.com>
1 parent 7fb9bdd commit 0c4094f

3 files changed

Lines changed: 90 additions & 2 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"PostHog": patch
3+
"PostHog.AspNetCore": patch
4+
---
5+
6+
Support fractional rollout percentages when evaluating feature flags locally.

src/PostHog/Api/LocalEvaluationApiResult.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,10 +219,10 @@ internal record FeatureFlagGroup
219219
public string? Variant { get; init; }
220220

221221
/// <summary>
222-
/// Optional percentage (0-100) for gradual rollouts. Defaults to 100.
222+
/// Optional percentage (0-100) for gradual rollouts. May be fractional (e.g. 0.5). Defaults to 100.
223223
/// </summary>
224224
[JsonPropertyName("rollout_percentage")]
225-
public int? RolloutPercentage { get; init; } = 100;
225+
public double? RolloutPercentage { get; init; } = 100;
226226

227227
/// <summary>
228228
/// Optional per-condition aggregation override used by mixed-targeting flags.

tests/UnitTests/Features/LocalEvaluatorTests.cs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -904,6 +904,88 @@ public void ThrowsInconclusiveMatchExceptionWhenUnknownOperator(ComparisonOperat
904904
personProperties: properties);
905905
});
906906
}
907+
908+
// A fractional rollout such as 0.1% has to survive deserialization and bucketing.
909+
// `user-2912` hashes to ~0.0000142 (inside 0.1%) and `user-212` to ~0.0042866
910+
// (outside 0.1%, but inside 0.5%). Truncating the percentage to an integer would
911+
// put both users outside the bucket.
912+
[Theory]
913+
[InlineData("0.1", "user-2912", true)]
914+
[InlineData("0.1", "user-212", false)]
915+
[InlineData("0.5", "user-212", true)]
916+
public void MatchesFractionalRolloutPercentage(string rolloutPercentage, string distinctId, bool expected)
917+
{
918+
var json = $$"""
919+
{
920+
"flags": [
921+
{
922+
"id": 42,
923+
"team_id": 23,
924+
"name": "fractional-rollout-feature-flag",
925+
"key": "fractional-rollout",
926+
"active": true,
927+
"filters": {
928+
"groups": [
929+
{
930+
"properties": [],
931+
"rollout_percentage": {{rolloutPercentage}}
932+
}
933+
]
934+
}
935+
}
936+
],
937+
"group_type_mapping": {}
938+
}
939+
""";
940+
var flags = JsonSerializer.Deserialize<LocalEvaluationApiResult>(json, JsonSerializerHelper.Options)!;
941+
var localEvaluator = new LocalEvaluator(flags);
942+
943+
var result = localEvaluator.EvaluateFeatureFlag(key: "fractional-rollout", distinctId: distinctId);
944+
945+
Assert.Equal(expected, result);
946+
}
947+
948+
// The boundaries have to keep behaving after widening the percentage to a double:
949+
// 100.0 lets everybody through, 0.0 lets nobody through, and an explicit null is
950+
// treated as an unbounded rollout.
951+
[Theory]
952+
[InlineData("100.0", "user-2912", true)]
953+
[InlineData("100.0", "user-212", true)]
954+
[InlineData("0.0", "user-2912", false)]
955+
[InlineData("0.0", "user-212", false)]
956+
[InlineData("null", "user-2912", true)]
957+
[InlineData("null", "user-212", true)]
958+
public void MatchesBoundaryRolloutPercentage(string rolloutPercentage, string distinctId, bool expected)
959+
{
960+
var json = $$"""
961+
{
962+
"flags": [
963+
{
964+
"id": 42,
965+
"team_id": 23,
966+
"name": "boundary-rollout-feature-flag",
967+
"key": "boundary-rollout",
968+
"active": true,
969+
"filters": {
970+
"groups": [
971+
{
972+
"properties": [],
973+
"rollout_percentage": {{rolloutPercentage}}
974+
}
975+
]
976+
}
977+
}
978+
],
979+
"group_type_mapping": {}
980+
}
981+
""";
982+
var flags = JsonSerializer.Deserialize<LocalEvaluationApiResult>(json, JsonSerializerHelper.Options)!;
983+
var localEvaluator = new LocalEvaluator(flags);
984+
985+
var result = localEvaluator.EvaluateFeatureFlag(key: "boundary-rollout", distinctId: distinctId);
986+
987+
Assert.Equal(expected, result);
988+
}
907989
}
908990

909991
public class TheMixedTargetingEvaluation

0 commit comments

Comments
 (0)