Skip to content

Commit

Permalink
feat: report prerequisite relations in AllFlagState (#19)
Browse files Browse the repository at this point in the history
This commit updates `AllFlagsState` to track prerequisite evaluations. 

This didn't require modifying the Evaluator interface, as it already
returns prerequisites as part of the `EvalResult`.

When the returned `FlagState` is marshaled to JSON, it will now contain
the prerequisite relationships, if any, for each flag.
  • Loading branch information
cwaldren-ld authored Oct 23, 2024
1 parent 7b07e23 commit 43da95c
Show file tree
Hide file tree
Showing 8 changed files with 362 additions and 41 deletions.
3 changes: 2 additions & 1 deletion pkgs/sdk/server/contract-tests/TestService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ public class Webapp
"tags",
"inline-context",
"anonymous-redaction",
"evaluation-hooks"
"evaluation-hooks",
"client-prereq-events"
};

public readonly Handler Handler;
Expand Down
112 changes: 92 additions & 20 deletions pkgs/sdk/server/src/FeatureFlagsState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ public FeatureFlagsState Build()
/// </summary>
/// <param name="valid">true if valid, false if invalid (default is valid)</param>
/// <returns>the same builder</returns>
[Obsolete("Unused, construct a FeatureFlagState with valid/invalid state directly")]
public FeatureFlagsStateBuilder Valid(bool valid)
{
_valid = valid;
Expand All @@ -167,8 +168,21 @@ public FeatureFlagsStateBuilder Valid(bool valid)
/// </summary>
/// <param name="flagKey">the flag key</param>
/// <param name="result">the evaluation result</param>
/// <returns></returns>
/// <returns>the same builder</returns>
public FeatureFlagsStateBuilder AddFlag(string flagKey, EvaluationDetail<LdValue> result)
{
return AddFlag(flagKey, result, new List<string>());
}


/// <summary>
/// Adds the result of a flag evaluation, including direct prerequisites.
/// </summary>
/// <param name="flagKey">the flag key</param>
/// <param name="result">the evaluation result</param>
/// <param name="prerequisites">the direct prerequisites evaluated for this flag</param>
/// <returns>the same builder</returns>
public FeatureFlagsStateBuilder AddFlag(string flagKey, EvaluationDetail<LdValue> result, List<string> prerequisites)
{
return AddFlag(flagKey,
result.Value,
Expand All @@ -177,13 +191,14 @@ public FeatureFlagsStateBuilder AddFlag(string flagKey, EvaluationDetail<LdValue
0,
false,
false,
null);
null,
prerequisites);
}

// This method is defined with internal scope because metadata fields like trackEvents aren't
// relevant to the main external use case for the builder (testing server-side code)
internal FeatureFlagsStateBuilder AddFlag(string flagKey, LdValue value, int? variationIndex, EvaluationReason reason,
int flagVersion, bool flagTrackEvents, bool trackReason, UnixMillisecondTime? flagDebugEventsUntilDate)
int flagVersion, bool flagTrackEvents, bool trackReason, UnixMillisecondTime? flagDebugEventsUntilDate, List<string> prerequisites)
{
bool flagIsTracked = flagTrackEvents || flagDebugEventsUntilDate != null;
var flag = new FlagState
Expand All @@ -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<FlagState>
{
internal LdValue Value { get; set; }
internal int? Variation { get; set; }
Expand All @@ -211,24 +227,38 @@ internal struct FlagState
internal UnixMillisecondTime? DebugEventsUntilDate { get; set; }
internal EvaluationReason? Reason { get; set; }

public override bool Equals(object other)
internal IReadOnlyList<string> 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;
}
}

Expand Down Expand Up @@ -271,6 +301,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();
Expand All @@ -282,6 +320,7 @@ public override FeatureFlagsState Read(ref Utf8JsonReader reader, Type typeToCon
{
var valid = true;
var flags = new Dictionary<string, FlagState>();

for (var topLevelObj = RequireObject(ref reader); topLevelObj.Next(ref reader);)
{
var key = topLevelObj.Name;
Expand All @@ -295,7 +334,15 @@ 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
{
// Most flags have no prerequisites, don't allocate capacity unless we need to.
Prerequisites = new List<string>(0)
};

for (var metaObj = RequireObject(ref reader); metaObj.Next(ref reader);)
{
switch (metaObj.Name)
Expand All @@ -318,14 +365,39 @@ public override FeatureFlagsState Read(ref Utf8JsonReader reader, Type typeToCon
flag.Reason = reader.TokenType == JsonTokenType.Null ? (EvaluationReason?)null :
EvaluationReasonConverter.ReadJsonValue(ref reader);
break;
case "prerequisites":
// Note: there is an assumption in this code that a given flag key could already
// have been seen before: specifically in the "values" section of the data
// (where it's a simple map of flag key -> evaluated value), but *also* if we
// have duplicate flag keys under the $flagState key.
//
// The first case is expected, but the second is not. LaunchDarkly SaaS / SDKs
// should never generate JSON that has duplicate keys. If this did happen,
// we don't want to 'merge' prerequisites in an arbitrary order: it's important
// that they remain the order they were serialized originally.
//
// Therefore, the behavior here is that the last seen value for a key will 'win'
// and overwrite any previous value.
var prereqList = new List<string>();
for (var prereqs = RequireArray(ref reader); prereqs.Next(ref reader);)
{
prereqList.Add(reader.GetString());

}
flag.Prerequisites = prereqList;
break;
}
}
flags[subKey] = flag;
}
break;

default:
var flagForValue = flags.ContainsKey(key) ? flags[key] : new FlagState();
var flagForValue = flags.ContainsKey(key) ? flags[key] : new FlagState
{
// Most flags have no prerequisites, don't allocate capacity unless we need to.
Prerequisites = new List<string>(0)
};
flagForValue.Value = LdValueConverter.ReadJsonValue(ref reader);
flags[key] = flagForValue;
break;
Expand Down
6 changes: 3 additions & 3 deletions pkgs/sdk/server/src/Internal/Evaluation/EvaluatorTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@ internal EvalResult(EvaluationDetail<LdValue> result, IList<PrerequisiteEvalReco
internal struct PrerequisiteEvalRecord
{
internal readonly FeatureFlag PrerequisiteFlag;
internal readonly string PrerequisiteOfFlagKey;
internal readonly string FlagKey;
internal readonly EvaluationDetail<LdValue> Result;

internal PrerequisiteEvalRecord(FeatureFlag prerequisiteFlag, string prerequisiteOfFlagKey,
internal PrerequisiteEvalRecord(FeatureFlag prerequisiteFlag, string flagKey,
EvaluationDetail<LdValue> result)
{
PrerequisiteFlag = prerequisiteFlag;
PrerequisiteOfFlagKey = prerequisiteOfFlagKey;
FlagKey = flagKey;
Result = result;
}
}
Expand Down
17 changes: 11 additions & 6 deletions pkgs/sdk/server/src/LdClient.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using LaunchDarkly.Logging;
Expand Down Expand Up @@ -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<ItemDescriptor> flags;
try
{
Expand All @@ -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,
Expand All @@ -405,16 +410,16 @@ public FeatureFlagsState AllFlagsState(Context context, params FlagsStateOption[
flag.Version,
flag.TrackEvents || inExperiment,
inExperiment,
flag.DebugEventsUntilDate
);
flag.DebugEventsUntilDate,
directPrerequisites);
}
catch (Exception e)
{
LogHelpers.LogException(_evalLog,
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>(LdValue.Null, null, reason));
builder.AddFlag(flag.Key, new EvaluationDetail<LdValue>(LdValue.Null, null, reason), new List<string>());
}
}
return builder.Build();
Expand Down Expand Up @@ -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;
Expand Down
61 changes: 55 additions & 6 deletions pkgs/sdk/server/test/FeatureFlagsStateTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>())
.AddFlag("key2", LdValue.Of("value2"), 1, EvaluationReason.FallthroughReason, 200, true, false,
UnixMillisecondTime.OfMillis(1000), new List<string>())
.AddFlag("key3", LdValue.Null, null, EvaluationReason.ErrorReason(EvaluationErrorKind.MalformedFlag),
300, false, false, null, new List<string>()
)
.Build();

var expectedString = @"{""key1"":""value1"",""key2"":""value2"",""key3"":null,
Expand All @@ -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<string>())
.AddFlag("prereq2", LdValue.Of("value2"), 1, EvaluationReason.FallthroughReason, 200, true, false,
UnixMillisecondTime.OfMillis(1000), new List<string>())
.AddFlag("toplevel", LdValue.Null, null,
EvaluationReason.ErrorReason(EvaluationErrorKind.MalformedFlag), 300, false, false, null,
new List<string>
{
"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<string>())
.AddFlag("key2", LdValue.Of("value2"), 1, EvaluationReason.FallthroughReason, 200, true, false,
UnixMillisecondTime.OfMillis(1000), new List<string> { "key1" })
.Build();

var jsonString = LdJsonSerialization.SerializeObject(state);
var state1 = LdJsonSerialization.DeserializeObject<FeatureFlagsState>(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);
}
}
}



}
Loading

0 comments on commit 43da95c

Please sign in to comment.