diff --git a/pkgs/sdk/server/contract-tests/TestService.cs b/pkgs/sdk/server/contract-tests/TestService.cs index d6e7da05..50a008f0 100644 --- a/pkgs/sdk/server/contract-tests/TestService.cs +++ b/pkgs/sdk/server/contract-tests/TestService.cs @@ -37,7 +37,8 @@ public class Webapp "tags", "inline-context", "anonymous-redaction", - "evaluation-hooks" + "evaluation-hooks", + "client-prereq-events" }; public readonly Handler Handler; diff --git a/pkgs/sdk/server/src/FeatureFlagsState.cs b/pkgs/sdk/server/src/FeatureFlagsState.cs index 7d5ea093..e7603726 100644 --- a/pkgs/sdk/server/src/FeatureFlagsState.cs +++ b/pkgs/sdk/server/src/FeatureFlagsState.cs @@ -156,6 +156,7 @@ public FeatureFlagsState Build() /// /// true if valid, false if invalid (default is valid) /// the same builder + [Obsolete("Unused, construct a FeatureFlagState with valid/invalid state directly")] public FeatureFlagsStateBuilder Valid(bool valid) { _valid = valid; @@ -169,6 +170,19 @@ public FeatureFlagsStateBuilder Valid(bool valid) /// the evaluation result /// public FeatureFlagsStateBuilder AddFlag(string flagKey, EvaluationDetail result) + { + return AddFlag(flagKey, result, new List()); + } + + + /// + /// Adds the result of a flag evaluation, including direct prerequisites. + /// + /// the flag key + /// the evaluation result + /// the direct prerequisites evaluated for this flag + /// + public FeatureFlagsStateBuilder AddFlag(string flagKey, EvaluationDetail result, List prerequisites) { return AddFlag(flagKey, result.Value, @@ -177,13 +191,14 @@ public FeatureFlagsStateBuilder AddFlag(string flagKey, EvaluationDetail prerequisites) { bool flagIsTracked = flagTrackEvents || flagDebugEventsUntilDate != null; var flag = new FlagState @@ -194,14 +209,15 @@ internal FeatureFlagsStateBuilder AddFlag(string flagKey, LdValue value, int? va Reason = trackReason || (_withReasons && (!_detailsOnlyIfTracked || flagIsTracked)) ? reason : (EvaluationReason?)null, DebugEventsUntilDate = flagDebugEventsUntilDate, TrackEvents = flagTrackEvents, - TrackReason = trackReason + TrackReason = trackReason, + Prerequisites = prerequisites }; _flags[flagKey] = flag; return this; } } - internal struct FlagState + internal struct FlagState : IEquatable { internal LdValue Value { get; set; } internal int? Variation { get; set; } @@ -211,24 +227,37 @@ internal struct FlagState internal UnixMillisecondTime? DebugEventsUntilDate { get; set; } internal EvaluationReason? Reason { get; set; } - public override bool Equals(object other) + internal List Prerequisites { get; set; } + + public bool Equals(FlagState o) { - if (other is FlagState o) - { - return Variation == o.Variation && - Version == o.Version && - TrackEvents == o.TrackEvents && - TrackReason == o.TrackReason && - DebugEventsUntilDate.Equals(o.DebugEventsUntilDate) && - Object.Equals(Reason, o.Reason); - } - return false; + return Variation == o.Variation && + Version == o.Version && + TrackEvents == o.TrackEvents && + TrackReason == o.TrackReason && + DebugEventsUntilDate.Equals(o.DebugEventsUntilDate) && + Object.Equals(Reason, o.Reason) && + Prerequisites.SequenceEqual(o.Prerequisites); + } + public override bool Equals(object obj) + { + return obj is FlagState other && Equals(other); + } + + public static bool operator ==(FlagState lhs, FlagState rhs) + { + return lhs.Equals(rhs); + } + + public static bool operator !=(FlagState lhs, FlagState rhs) + { + return !(lhs == rhs); } public override int GetHashCode() { - return new HashCodeBuilder().With(Variation).With(Version).With(TrackEvents).With(TrackReason). - With(DebugEventsUntilDate).With(Reason).Value; + return new HashCodeBuilder().With(Variation).With(Version).With(TrackEvents).With(TrackReason) + .With(DebugEventsUntilDate).With(Reason).With(Prerequisites).Value; } } @@ -271,6 +300,14 @@ public override void Write(Utf8JsonWriter w, FeatureFlagsState state, JsonSerial w.WritePropertyName("reason"); EvaluationReasonConverter.WriteJsonValue(meta.Reason.Value, w); } + if (meta.Prerequisites.Count > 0) { + w.WriteStartArray("prerequisites"); + foreach (var p in meta.Prerequisites) + { + w.WriteStringValue(p); + } + w.WriteEndArray(); + } w.WriteEndObject(); } w.WriteEndObject(); @@ -295,7 +332,10 @@ public override FeatureFlagsState Read(ref Utf8JsonReader reader, Type typeToCon for (var flagsObj = RequireObject(ref reader); flagsObj.Next(ref reader);) { var subKey = flagsObj.Name; - var flag = flags.ContainsKey(subKey) ? flags[subKey] : new FlagState(); + var flag = flags.ContainsKey(subKey) + ? flags[subKey] + : new FlagState() { Prerequisites = new List() }; + for (var metaObj = RequireObject(ref reader); metaObj.Next(ref reader);) { switch (metaObj.Name) @@ -318,6 +358,13 @@ public override FeatureFlagsState Read(ref Utf8JsonReader reader, Type typeToCon flag.Reason = reader.TokenType == JsonTokenType.Null ? (EvaluationReason?)null : EvaluationReasonConverter.ReadJsonValue(ref reader); break; + case "prerequisites": + flag.Prerequisites = new List(); + for (var prereqs = RequireArray(ref reader); prereqs.Next(ref reader);) + { + flag.Prerequisites.Add(reader.GetString()); + } + break; } } flags[subKey] = flag; @@ -325,7 +372,7 @@ public override FeatureFlagsState Read(ref Utf8JsonReader reader, Type typeToCon break; default: - var flagForValue = flags.ContainsKey(key) ? flags[key] : new FlagState(); + var flagForValue = flags.ContainsKey(key) ? flags[key] : new FlagState(){Prerequisites = new List()}; flagForValue.Value = LdValueConverter.ReadJsonValue(ref reader); flags[key] = flagForValue; break; diff --git a/pkgs/sdk/server/src/Internal/Evaluation/EvaluatorTypes.cs b/pkgs/sdk/server/src/Internal/Evaluation/EvaluatorTypes.cs index 561d81e8..3deb788d 100644 --- a/pkgs/sdk/server/src/Internal/Evaluation/EvaluatorTypes.cs +++ b/pkgs/sdk/server/src/Internal/Evaluation/EvaluatorTypes.cs @@ -21,14 +21,14 @@ internal EvalResult(EvaluationDetail result, IList Result; - internal PrerequisiteEvalRecord(FeatureFlag prerequisiteFlag, string prerequisiteOfFlagKey, + internal PrerequisiteEvalRecord(FeatureFlag prerequisiteFlag, string flagKey, EvaluationDetail result) { PrerequisiteFlag = prerequisiteFlag; - PrerequisiteOfFlagKey = prerequisiteOfFlagKey; + FlagKey = flagKey; Result = result; } } diff --git a/pkgs/sdk/server/src/LdClient.cs b/pkgs/sdk/server/src/LdClient.cs index cbff359a..ecc32f28 100644 --- a/pkgs/sdk/server/src/LdClient.cs +++ b/pkgs/sdk/server/src/LdClient.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Security.Cryptography; using LaunchDarkly.Logging; @@ -371,8 +372,7 @@ public FeatureFlagsState AllFlagsState(Context context, params FlagsStateOption[ var builder = new FeatureFlagsStateBuilder(options); var clientSideOnly = FlagsStateOption.HasOption(options, FlagsStateOption.ClientSideOnly); - var withReasons = FlagsStateOption.HasOption(options, FlagsStateOption.WithReasons); - var detailsOnlyIfTracked = FlagsStateOption.HasOption(options, FlagsStateOption.DetailsOnlyForTrackedFlags); + KeyedItems flags; try { @@ -397,6 +397,11 @@ public FeatureFlagsState AllFlagsState(Context context, params FlagsStateOption[ { EvaluatorTypes.EvalResult result = _evaluator.Evaluate(flag, context); bool inExperiment = EventFactory.IsExperiment(flag, result.Result.Reason); + + var directPrerequisites = result.PrerequisiteEvals.Where( + e => e.FlagKey == flag.Key) + .Select(p => p.PrerequisiteFlag.Key).ToList(); + builder.AddFlag( flag.Key, result.Result.Value, @@ -405,8 +410,8 @@ public FeatureFlagsState AllFlagsState(Context context, params FlagsStateOption[ flag.Version, flag.TrackEvents || inExperiment, inExperiment, - flag.DebugEventsUntilDate - ); + flag.DebugEventsUntilDate, + directPrerequisites); } catch (Exception e) { @@ -414,7 +419,7 @@ public FeatureFlagsState AllFlagsState(Context context, params FlagsStateOption[ string.Format("Exception caught for feature flag \"{0}\" when evaluating all flags", flag.Key), e); EvaluationReason reason = EvaluationReason.ErrorReason(EvaluationErrorKind.Exception); - builder.AddFlag(flag.Key, new EvaluationDetail(LdValue.Null, null, reason)); + builder.AddFlag(flag.Key, new EvaluationDetail(LdValue.Null, null, reason), new List()); } } return builder.Build(); @@ -477,7 +482,7 @@ public FeatureFlagsState AllFlagsState(Context context, params FlagsStateOption[ foreach (var prereqEvent in evalResult.PrerequisiteEvals) { _eventProcessor.RecordEvaluationEvent(eventFactory.NewPrerequisiteEvaluationEvent( - prereqEvent.PrerequisiteFlag, context, prereqEvent.Result, prereqEvent.PrerequisiteOfFlagKey)); + prereqEvent.PrerequisiteFlag, context, prereqEvent.Result, prereqEvent.FlagKey)); } } var evalDetail = evalResult.Result; diff --git a/pkgs/sdk/server/test/FeatureFlagsStateTest.cs b/pkgs/sdk/server/test/FeatureFlagsStateTest.cs index 685a323b..6d763953 100644 --- a/pkgs/sdk/server/test/FeatureFlagsStateTest.cs +++ b/pkgs/sdk/server/test/FeatureFlagsStateTest.cs @@ -71,9 +71,13 @@ public void CanConvertToValuesMap() public void CanSerializeToJson() { var state = FeatureFlagsState.Builder(FlagsStateOption.WithReasons) - .AddFlag("key1", LdValue.Of("value1"), 0, EvaluationReason.OffReason, 100, false, false, null) - .AddFlag("key2", LdValue.Of("value2"), 1, EvaluationReason.FallthroughReason, 200, true, false, UnixMillisecondTime.OfMillis(1000)) - .AddFlag("key3", LdValue.Null, null, EvaluationReason.ErrorReason(EvaluationErrorKind.MalformedFlag), 300, false, false, null) + .AddFlag("key1", LdValue.Of("value1"), 0, EvaluationReason.OffReason, 100, false, false, null, + new List()) + .AddFlag("key2", LdValue.Of("value2"), 1, EvaluationReason.FallthroughReason, 200, true, false, + UnixMillisecondTime.OfMillis(1000), new List()) + .AddFlag("key3", LdValue.Null, null, EvaluationReason.ErrorReason(EvaluationErrorKind.MalformedFlag), + 300, false, false, null, new List() + ) .Build(); var expectedString = @"{""key1"":""value1"",""key2"":""value2"",""key3"":null, @@ -92,18 +96,63 @@ public void CanSerializeToJson() JsonAssertions.AssertJsonEqual(expectedString, actualString); } + [Fact] + public void CanSerializeFlagPrerequisites() + { + var state = FeatureFlagsState.Builder(FlagsStateOption.WithReasons) + .AddFlag("prereq1", LdValue.Of("value1"), 0, EvaluationReason.OffReason, 100, false, false, null, + new List()) + .AddFlag("prereq2", LdValue.Of("value2"), 1, EvaluationReason.FallthroughReason, 200, true, false, + UnixMillisecondTime.OfMillis(1000), new List()) + .AddFlag("toplevel", LdValue.Null, null, + EvaluationReason.ErrorReason(EvaluationErrorKind.MalformedFlag), 300, false, false, null, + new List + { + "prereq1", "prereq2" + }) + .Build(); + + + var expectedString = @"{""prereq1"":""value1"",""prereq2"":""value2"",""toplevel"":null, + ""$flagsState"":{ + ""prereq1"":{ + ""variation"":0,""version"":100,""reason"":{""kind"":""OFF""} + },""prereq2"":{ + ""variation"":1,""version"":200,""reason"":{""kind"":""FALLTHROUGH""},""trackEvents"":true,""debugEventsUntilDate"":1000 + },""toplevel"":{ + ""version"":300,""reason"":{""kind"":""ERROR"",""errorKind"":""MALFORMED_FLAG""},""prerequisites"":[""prereq1"",""prereq2""] + } + }, + ""$valid"":true + }"; + var actualString = LdJsonSerialization.SerializeObject(state); + JsonAssertions.AssertJsonEqual(expectedString, actualString); + } + + [Fact] public void CanDeserializeFromJson() { var state = FeatureFlagsState.Builder(FlagsStateOption.WithReasons) - .AddFlag("key1", LdValue.Of("value1"), 0, EvaluationReason.OffReason, 100, false, false, null) - .AddFlag("key2", LdValue.Of("value2"), 1, EvaluationReason.FallthroughReason, 200, true, false, UnixMillisecondTime.OfMillis(1000)) + .AddFlag("key1", LdValue.Of("value1"), 0, EvaluationReason.OffReason, 100, false, false, null, + new List()) + .AddFlag("key2", LdValue.Of("value2"), 1, EvaluationReason.FallthroughReason, 200, true, false, + UnixMillisecondTime.OfMillis(1000), new List { "key1" }) .Build(); var jsonString = LdJsonSerialization.SerializeObject(state); var state1 = LdJsonSerialization.DeserializeObject(jsonString); + var jsonString2 = LdJsonSerialization.SerializeObject(state1); + + // Ensure a roundtrip state -> json -> json is equal. + Assert.Equal(jsonString, jsonString2); + + // Ensure a roundtrip state -> json -> state is equal. Assert.Equal(state, state1); } } -} \ No newline at end of file + + + +} diff --git a/pkgs/sdk/server/test/Internal/Evaluation/EvaluatorPrerequisitesTest.cs b/pkgs/sdk/server/test/Internal/Evaluation/EvaluatorPrerequisitesTest.cs index bc953375..913b43df 100644 --- a/pkgs/sdk/server/test/Internal/Evaluation/EvaluatorPrerequisitesTest.cs +++ b/pkgs/sdk/server/test/Internal/Evaluation/EvaluatorPrerequisitesTest.cs @@ -64,7 +64,7 @@ public void FlagReturnsOffVariationAndEventIfPrerequisiteIsOff() Assert.Equal(f1.Key, e.PrerequisiteFlag.Key); Assert.Equal(LdValue.Of("go"), e.Result.Value); Assert.Equal(f1.Version, e.PrerequisiteFlag.Version); - Assert.Equal(f0.Key, e.PrerequisiteOfFlagKey); + Assert.Equal(f0.Key, e.FlagKey); }); } @@ -99,7 +99,7 @@ public void FlagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() Assert.Equal(f1.Key, e.PrerequisiteFlag.Key); Assert.Equal(LdValue.Of("nogo"), e.Result.Value); Assert.Equal(f1.Version, e.PrerequisiteFlag.Version); - Assert.Equal(f0.Key, e.PrerequisiteOfFlagKey); + Assert.Equal(f0.Key, e.FlagKey); }); } @@ -133,7 +133,7 @@ public void FlagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAr Assert.Equal(f1.Key, e.PrerequisiteFlag.Key); Assert.Equal(LdValue.Of("go"), e.Result.Value); Assert.Equal(f1.Version, e.PrerequisiteFlag.Version); - Assert.Equal(f0.Key, e.PrerequisiteOfFlagKey); + Assert.Equal(f0.Key, e.FlagKey); }); } @@ -176,14 +176,14 @@ public void MultipleLevelsOfPrerequisitesProduceMultipleEvents() Assert.Equal(f2.Key, e.PrerequisiteFlag.Key); Assert.Equal(LdValue.Of("go"), e.Result.Value); Assert.Equal(f2.Version, e.PrerequisiteFlag.Version); - Assert.Equal(f1.Key, e.PrerequisiteOfFlagKey); + Assert.Equal(f1.Key, e.FlagKey); }, e => { Assert.Equal(f1.Key, e.PrerequisiteFlag.Key); Assert.Equal(LdValue.Of("go"), e.Result.Value); Assert.Equal(f1.Version, e.PrerequisiteFlag.Version); - Assert.Equal(f0.Key, e.PrerequisiteOfFlagKey); + Assert.Equal(f0.Key, e.FlagKey); }); } diff --git a/pkgs/sdk/server/test/Internal/Model/FeatureFlagBuilder.cs b/pkgs/sdk/server/test/Internal/Model/FeatureFlagBuilder.cs index 20c60fe0..c51975a5 100644 --- a/pkgs/sdk/server/test/Internal/Model/FeatureFlagBuilder.cs +++ b/pkgs/sdk/server/test/Internal/Model/FeatureFlagBuilder.cs @@ -202,6 +202,11 @@ internal FeatureFlagBuilder OffWithValue(LdValue value) return On(false).OffVariation(0).Variations(value); } + internal FeatureFlagBuilder OnWithValue(LdValue value) + { + return On(true).OffVariation(0).FallthroughVariation(0).Variations(value); + } + internal FeatureFlagBuilder BooleanWithClauses(params Clause[] clauses) { return On(true).OffVariation(0) diff --git a/pkgs/sdk/server/test/LdClientEvaluationTest.cs b/pkgs/sdk/server/test/LdClientEvaluationTest.cs index 3fea194a..b63c074e 100644 --- a/pkgs/sdk/server/test/LdClientEvaluationTest.cs +++ b/pkgs/sdk/server/test/LdClientEvaluationTest.cs @@ -389,6 +389,195 @@ public void ExceptionWhenEvaluatingFlagInAllFlagsIsHandledCorrectly() AssertLogMessageRegex(true, Logging.LogLevel.Error, Evaluator.ErrorMessageForTesting); } + + + [Fact] + public void AllFlagsStateCanExposePrerequisiteRelationshipsWhenPrereqIsNotVisibleToClients() + { + var prereq1 = new FeatureFlagBuilder("prereq1") + .OnWithValue(LdValue.Of(true)).ClientSide(false).Build(); + + var prereq2 = new FeatureFlagBuilder("prereq2") + .OnWithValue(LdValue.Of(true)).ClientSide(false).Build(); + + var toplevel = new FeatureFlagBuilder("toplevel") + .Prerequisites(new Prerequisite("prereq1", 0), new Prerequisite("prereq2", 0)) + .Variations(LdValue.Of(false), LdValue.Of(true)) + .On(true) + .ClientSide(true) + .OffVariation(0) + .FallthroughVariation(1) + .Build(); + + testData.UsePreconfiguredFlag(prereq1); + testData.UsePreconfiguredFlag(prereq2); + testData.UsePreconfiguredFlag(toplevel); + + var state = client.AllFlagsState(context, FlagsStateOption.ClientSideOnly); + Assert.True(state.Valid); + + + var expectedString = @"{""toplevel"":true, + ""$flagsState"":{ + ""toplevel"":{ + ""variation"":1,""version"":1,""prerequisites"":[ + ""prereq1"",""prereq2"" + ] + } + }, + ""$valid"":true + }"; + + var actualString = LdJsonSerialization.SerializeObject(state); + + JsonAssertions.AssertJsonEqual(expectedString, actualString); + } + + [Fact] + public void AllFlagsStateCanExposePrerequisiteRelationshipsInEvaluationOrderShortCircuit() + { + var prereq1 = new FeatureFlagBuilder("prereq1") + .OffWithValue(LdValue.Of(false)).Build(); + + var prereq2 = new FeatureFlagBuilder("prereq2") + .OnWithValue(LdValue.Of(true)).Build(); + + var toplevel = new FeatureFlagBuilder("toplevel") + .Prerequisites(new Prerequisite("prereq1", 0), new Prerequisite("prereq2", 0)) + .Variations(LdValue.Of(false), LdValue.Of(true)) + .On(true) + .OffVariation(0) + .FallthroughVariation(1) + .Build(); + + testData.UsePreconfiguredFlag(prereq1); + testData.UsePreconfiguredFlag(prereq2); + testData.UsePreconfiguredFlag(toplevel); + + var state = client.AllFlagsState(context); + Assert.True(state.Valid); + + + var expectedString = @"{""prereq1"":false,""prereq2"":true,""toplevel"":false, + ""$flagsState"":{ + ""prereq1"":{ + ""variation"":0, + ""version"":1 + },""prereq2"":{ + ""variation"":0, + ""version"":1 + },""toplevel"":{ + ""variation"":0,""version"":1,""prerequisites"":[ + ""prereq1"" + ] + } + }, + ""$valid"":true + }"; + + var actualString = LdJsonSerialization.SerializeObject(state); + + JsonAssertions.AssertJsonEqual(expectedString, actualString); + } + + [Fact] + public void AllFlagsStateCanExposePrerequisiteRelationshipsInEvaluationOrderBothOn() + { + var prereq1 = new FeatureFlagBuilder("prereq1") + .OnWithValue(LdValue.Of(true)).Build(); + + var prereq2 = new FeatureFlagBuilder("prereq2") + .OnWithValue(LdValue.Of(true)).Build(); + + var toplevel = new FeatureFlagBuilder("toplevel") + .Prerequisites(new Prerequisite("prereq1", 0), new Prerequisite("prereq2", 0)) + .Variations(LdValue.Of(false), LdValue.Of(true)) + .On(true) + .OffVariation(0) + .FallthroughVariation(1) + .Build(); + + testData.UsePreconfiguredFlag(prereq1); + testData.UsePreconfiguredFlag(prereq2); + testData.UsePreconfiguredFlag(toplevel); + + var state = client.AllFlagsState(context); + Assert.True(state.Valid); + + + var expectedString = @"{""prereq1"":true,""prereq2"":true,""toplevel"":true, + ""$flagsState"":{ + ""prereq1"":{ + ""variation"":0, + ""version"":1 + },""prereq2"":{ + ""variation"":0, + ""version"":1 + },""toplevel"":{ + ""variation"":1,""version"":1,""prerequisites"":[ + ""prereq1"",""prereq2"" + ] + } + }, + ""$valid"":true + }"; + + var actualString = LdJsonSerialization.SerializeObject(state); + + JsonAssertions.AssertJsonEqual(expectedString, actualString); + } + + + [Fact] + public void AllFlagsStateCanExposePrerequisiteRelationshipsInEvaluationOrderBothOnSwapped() + { + // Same as previous test, but the order of prerequisites in the toplevel flag is swapped. This is to + // ensure we're not sorting the prerequisite list. + + var prereq1 = new FeatureFlagBuilder("prereq1") + .OnWithValue(LdValue.Of(true)).Build(); + + var prereq2 = new FeatureFlagBuilder("prereq2") + .OnWithValue(LdValue.Of(true)).Build(); + + var toplevel = new FeatureFlagBuilder("toplevel") + .Prerequisites(new Prerequisite("prereq2", 0), new Prerequisite("prereq1", 0)) // swapped + .Variations(LdValue.Of(false), LdValue.Of(true)) + .On(true) + .OffVariation(0) + .FallthroughVariation(1) + .Build(); + + testData.UsePreconfiguredFlag(prereq1); + testData.UsePreconfiguredFlag(prereq2); + testData.UsePreconfiguredFlag(toplevel); + + var state = client.AllFlagsState(context); + Assert.True(state.Valid); + + + var expectedString = @"{""prereq1"":true,""prereq2"":true,""toplevel"":true, + ""$flagsState"":{ + ""prereq1"":{ + ""variation"":0, + ""version"":1 + },""prereq2"":{ + ""variation"":0, + ""version"":1 + },""toplevel"":{ + ""variation"":1,""version"":1,""prerequisites"":[ + ""prereq2"",""prereq1"" + ] + } + }, + ""$valid"":true + }"; + + var actualString = LdJsonSerialization.SerializeObject(state); + + JsonAssertions.AssertJsonEqual(expectedString, actualString); + } + [Theory] [InlineData(MigrationStage.Off)] [InlineData(MigrationStage.DualWrite)]