Skip to content

Commit a81de77

Browse files
[AI-FSSDK] [FSSDK-12337] Add Feature Rollout support
1 parent c750ae9 commit a81de77

3 files changed

Lines changed: 352 additions & 0 deletions

File tree

OptimizelySDK.Tests/ProjectConfigTest.cs

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1491,4 +1491,251 @@ public void TestCmabFieldPopulation()
14911491
Assert.IsNull(experimentWithoutCmab.Cmab);
14921492
}
14931493
}
1494+
1495+
[TestFixture]
1496+
public class FeatureRolloutProjectConfigTest
1497+
{
1498+
private string BuildFeatureRolloutDatafile(
1499+
string experimentType = "feature_rollout",
1500+
string rolloutId = "rollout_1",
1501+
bool includeRollout = true,
1502+
bool includeRolloutVariations = true)
1503+
{
1504+
var rolloutExperiments = new List<object>();
1505+
if (includeRollout && includeRolloutVariations)
1506+
{
1507+
rolloutExperiments.Add(new
1508+
{
1509+
id = "rollout_rule_1",
1510+
key = "rollout_rule_1_key",
1511+
layerId = "rollout_layer_1",
1512+
status = "Running",
1513+
variations = new[]
1514+
{
1515+
new { id = "var_rr1", key = "variation_rr1", featureEnabled = true },
1516+
},
1517+
trafficAllocation = new[]
1518+
{
1519+
new { entityId = "var_rr1", endOfRange = 10000 },
1520+
},
1521+
audienceIds = new string[0],
1522+
forcedVariations = new Dictionary<string, string>(),
1523+
});
1524+
rolloutExperiments.Add(new
1525+
{
1526+
id = "rollout_everyone_else",
1527+
key = "rollout_everyone_else_key",
1528+
layerId = "rollout_layer_ee",
1529+
status = "Running",
1530+
variations = new[]
1531+
{
1532+
new
1533+
{
1534+
id = "var_ee", key = "variation_everyone_else",
1535+
featureEnabled = false,
1536+
},
1537+
},
1538+
trafficAllocation = new[]
1539+
{
1540+
new { entityId = "var_ee", endOfRange = 10000 },
1541+
},
1542+
audienceIds = new string[0],
1543+
forcedVariations = new Dictionary<string, string>(),
1544+
});
1545+
}
1546+
1547+
var rollouts = new List<object>();
1548+
if (includeRollout)
1549+
{
1550+
rollouts.Add(new
1551+
{
1552+
id = "rollout_1",
1553+
experiments = rolloutExperiments,
1554+
});
1555+
}
1556+
1557+
var experiments = new List<object>
1558+
{
1559+
new
1560+
{
1561+
id = "exp_ab",
1562+
key = "ab_experiment",
1563+
layerId = "layer_ab",
1564+
status = "Running",
1565+
variations = new[]
1566+
{
1567+
new { id = "var_ab_1", key = "variation_ab_1", featureEnabled = true },
1568+
},
1569+
trafficAllocation = new[]
1570+
{
1571+
new { entityId = "var_ab_1", endOfRange = 10000 },
1572+
},
1573+
audienceIds = new string[0],
1574+
forcedVariations = new Dictionary<string, string>(),
1575+
},
1576+
};
1577+
1578+
if (experimentType != null)
1579+
{
1580+
experiments.Add(new
1581+
{
1582+
id = "exp_rollout",
1583+
key = "rollout_experiment",
1584+
layerId = "layer_rollout",
1585+
status = "Running",
1586+
type = experimentType,
1587+
variations = new[]
1588+
{
1589+
new
1590+
{
1591+
id = "var_rollout_1", key = "variation_rollout_1",
1592+
featureEnabled = true,
1593+
},
1594+
},
1595+
trafficAllocation = new[]
1596+
{
1597+
new { entityId = "var_rollout_1", endOfRange = 5000 },
1598+
},
1599+
audienceIds = new string[0],
1600+
forcedVariations = new Dictionary<string, string>(),
1601+
});
1602+
}
1603+
1604+
var datafile = new
1605+
{
1606+
version = "4",
1607+
revision = "1",
1608+
projectId = "rollout_test",
1609+
accountId = "12345",
1610+
sdkKey = "test-key",
1611+
environmentKey = "production",
1612+
events = new object[0],
1613+
audiences = new object[0],
1614+
typedAudiences = new object[0],
1615+
attributes = new object[0],
1616+
groups = new object[0],
1617+
integrations = new object[0],
1618+
holdouts = new object[0],
1619+
experiments = experiments,
1620+
rollouts = rollouts,
1621+
featureFlags = new[]
1622+
{
1623+
new
1624+
{
1625+
id = "feature_1",
1626+
key = "feature_rollout_flag",
1627+
rolloutId = rolloutId,
1628+
experimentIds = experimentType != null
1629+
? new[] { "exp_ab", "exp_rollout" }
1630+
: new[] { "exp_ab" },
1631+
variables = new object[0],
1632+
},
1633+
},
1634+
};
1635+
1636+
return JsonConvert.SerializeObject(datafile);
1637+
}
1638+
1639+
[Test]
1640+
public void TestBackwardCompatibilityExperimentsWithoutTypeField()
1641+
{
1642+
var datafile = BuildFeatureRolloutDatafile();
1643+
var config = DatafileProjectConfig.Create(datafile, new NoOpLogger(),
1644+
new NoOpErrorHandler());
1645+
1646+
var abExperiment = config.GetExperimentFromKey("ab_experiment");
1647+
Assert.IsNull(abExperiment.Type);
1648+
}
1649+
1650+
[Test]
1651+
public void TestFeatureRolloutInjectionAddsEveryoneElseVariation()
1652+
{
1653+
var datafile = BuildFeatureRolloutDatafile();
1654+
var config = DatafileProjectConfig.Create(datafile, new NoOpLogger(),
1655+
new NoOpErrorHandler());
1656+
1657+
var rolloutExperiment = config.GetExperimentFromKey("rollout_experiment");
1658+
1659+
// Should have 2 variations: original + injected everyone else
1660+
Assert.AreEqual(2, rolloutExperiment.Variations.Length);
1661+
Assert.AreEqual("var_ee", rolloutExperiment.Variations[1].Id);
1662+
Assert.AreEqual("variation_everyone_else", rolloutExperiment.Variations[1].Key);
1663+
1664+
// Should have injected traffic allocation entry
1665+
var lastAllocation =
1666+
rolloutExperiment.TrafficAllocation[rolloutExperiment.TrafficAllocation.Length - 1];
1667+
Assert.AreEqual("var_ee", lastAllocation.EntityId);
1668+
Assert.AreEqual(10000, lastAllocation.EndOfRange);
1669+
}
1670+
1671+
[Test]
1672+
public void TestVariationMapsUpdatedWithInjectedVariation()
1673+
{
1674+
var datafile = BuildFeatureRolloutDatafile();
1675+
var config = DatafileProjectConfig.Create(datafile, new NoOpLogger(),
1676+
new NoOpErrorHandler());
1677+
1678+
var rolloutExperiment = config.GetExperimentFromKey("rollout_experiment");
1679+
1680+
// VariationKeyToVariationMap should contain the injected variation
1681+
Assert.IsTrue(
1682+
rolloutExperiment.VariationKeyToVariationMap.ContainsKey(
1683+
"variation_everyone_else"));
1684+
Assert.AreEqual("var_ee",
1685+
rolloutExperiment.VariationKeyToVariationMap["variation_everyone_else"].Id);
1686+
1687+
// VariationIdToVariationMap should contain the injected variation
1688+
Assert.IsTrue(
1689+
rolloutExperiment.VariationIdToVariationMap.ContainsKey("var_ee"));
1690+
1691+
// Global variation maps should also contain the injected variation
1692+
var variationByKey = config.GetVariationFromKey("rollout_experiment",
1693+
"variation_everyone_else");
1694+
Assert.AreEqual("var_ee", variationByKey.Id);
1695+
1696+
var variationById =
1697+
config.GetVariationFromId("rollout_experiment", "var_ee");
1698+
Assert.AreEqual("variation_everyone_else", variationById.Key);
1699+
}
1700+
1701+
[Test]
1702+
public void TestNonRolloutExperimentsNotModified()
1703+
{
1704+
var datafile = BuildFeatureRolloutDatafile();
1705+
var config = DatafileProjectConfig.Create(datafile, new NoOpLogger(),
1706+
new NoOpErrorHandler());
1707+
1708+
var abExperiment = config.GetExperimentFromKey("ab_experiment");
1709+
1710+
// A/B experiment should still have only 1 variation
1711+
Assert.AreEqual(1, abExperiment.Variations.Length);
1712+
Assert.AreEqual("var_ab_1", abExperiment.Variations[0].Id);
1713+
Assert.AreEqual(1, abExperiment.TrafficAllocation.Length);
1714+
}
1715+
1716+
[Test]
1717+
public void TestNoRolloutEdgeCaseSilentSkip()
1718+
{
1719+
var datafile = BuildFeatureRolloutDatafile(rolloutId: "");
1720+
var config = DatafileProjectConfig.Create(datafile, new NoOpLogger(),
1721+
new NoOpErrorHandler());
1722+
1723+
var rolloutExperiment = config.GetExperimentFromKey("rollout_experiment");
1724+
1725+
// Should still have only 1 variation (no injection)
1726+
Assert.AreEqual(1, rolloutExperiment.Variations.Length);
1727+
Assert.AreEqual("var_rollout_1", rolloutExperiment.Variations[0].Id);
1728+
}
1729+
1730+
[Test]
1731+
public void TestTypeFieldParsedCorrectly()
1732+
{
1733+
var datafile = BuildFeatureRolloutDatafile();
1734+
var config = DatafileProjectConfig.Create(datafile, new NoOpLogger(),
1735+
new NoOpErrorHandler());
1736+
1737+
var rolloutExperiment = config.GetExperimentFromKey("rollout_experiment");
1738+
Assert.AreEqual("feature_rollout", rolloutExperiment.Type);
1739+
}
1740+
}
14941741
}

OptimizelySDK/Config/DatafileProjectConfig.cs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,73 @@ private void Initialize()
445445
}
446446
}
447447

448+
// Inject "everyone else" variation into feature_rollout experiments
449+
foreach (var feature in FeatureFlags)
450+
{
451+
var everyoneElseVariation = GetEveryoneElseVariation(feature);
452+
if (everyoneElseVariation == null)
453+
{
454+
continue;
455+
}
456+
457+
foreach (var experimentId in feature.ExperimentIds ?? new List<string>())
458+
{
459+
if (!_ExperimentIdMap.ContainsKey(experimentId))
460+
{
461+
continue;
462+
}
463+
464+
var experiment = _ExperimentIdMap[experimentId];
465+
if (experiment.Type != "feature_rollout")
466+
{
467+
continue;
468+
}
469+
470+
// Append the everyone else variation
471+
var variationsList = experiment.Variations?.ToList() ?? new List<Variation>();
472+
variationsList.Add(everyoneElseVariation);
473+
experiment.Variations = variationsList.ToArray();
474+
475+
// Append traffic allocation entry
476+
var trafficList = experiment.TrafficAllocation?.ToList() ??
477+
new List<TrafficAllocation>();
478+
trafficList.Add(new TrafficAllocation
479+
{
480+
EntityId = everyoneElseVariation.Id,
481+
EndOfRange = 10000,
482+
});
483+
experiment.TrafficAllocation = trafficList.ToArray();
484+
485+
// Regenerate variation key maps for this experiment
486+
experiment.GenerateVariationKeyMap();
487+
488+
// Update global variation maps
489+
if (_VariationKeyMap.ContainsKey(experiment.Key))
490+
{
491+
_VariationKeyMap[experiment.Key][everyoneElseVariation.Key] =
492+
everyoneElseVariation;
493+
}
494+
495+
if (_VariationIdMap.ContainsKey(experiment.Key))
496+
{
497+
_VariationIdMap[experiment.Key][everyoneElseVariation.Id] =
498+
everyoneElseVariation;
499+
}
500+
501+
if (_VariationKeyMapByExperimentId.ContainsKey(experiment.Id))
502+
{
503+
_VariationKeyMapByExperimentId[experiment.Id]
504+
[everyoneElseVariation.Key] = everyoneElseVariation;
505+
}
506+
507+
if (_VariationIdMapByExperimentId.ContainsKey(experiment.Id))
508+
{
509+
_VariationIdMapByExperimentId[experiment.Id]
510+
[everyoneElseVariation.Id] = everyoneElseVariation;
511+
}
512+
}
513+
}
514+
448515
var integration = Integrations.FirstOrDefault(i => i.Key.ToLower() == "odp");
449516
HostForOdp = integration?.Host;
450517
PublicKeyForOdp = integration?.PublicKey;
@@ -916,5 +983,36 @@ public Holdout[] GetHoldoutsForFlag(string flagId)
916983
/// </summary>
917984
/// <returns>the datafile string corresponding to ProjectConfig</returns>
918985
public string Region { get; set; }
986+
987+
/// <summary>
988+
/// Get the "everyone else" variation from the last rule in the flag's rollout.
989+
/// Returns null if the rollout cannot be resolved or has no variations.
990+
/// </summary>
991+
private Variation GetEveryoneElseVariation(FeatureFlag feature)
992+
{
993+
if (string.IsNullOrEmpty(feature.RolloutId))
994+
{
995+
return null;
996+
}
997+
998+
if (!_RolloutIdMap.ContainsKey(feature.RolloutId))
999+
{
1000+
return null;
1001+
}
1002+
1003+
var rollout = _RolloutIdMap[feature.RolloutId];
1004+
if (rollout.Experiments == null || rollout.Experiments.Count == 0)
1005+
{
1006+
return null;
1007+
}
1008+
1009+
var everyoneElseRule = rollout.Experiments[rollout.Experiments.Count - 1];
1010+
if (everyoneElseRule.Variations == null || everyoneElseRule.Variations.Length == 0)
1011+
{
1012+
return null;
1013+
}
1014+
1015+
return everyoneElseRule.Variations[0];
1016+
}
9191017
}
9201018
}

OptimizelySDK/Entity/Experiment.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ public class Experiment : ExperimentCore
4949
public bool IsInMutexGroup =>
5050
!string.IsNullOrEmpty(GroupPolicy) && GroupPolicy == MUTEX_GROUP_POLICY;
5151

52+
/// <summary>
53+
/// Type of the experiment (e.g., "a/b", "feature_rollout", "targeted_delivery", etc.)
54+
/// Optional - old datafiles will not have this field.
55+
/// </summary>
56+
[JsonProperty("type")]
57+
public string Type { get; set; }
58+
5259
/// <summary>
5360
/// CMAB (Contextual Multi-Armed Bandit) configuration for the experiment.
5461
/// </summary>

0 commit comments

Comments
 (0)