Skip to content

Commit 43da95c

Browse files
authored
feat: report prerequisite relations in AllFlagState (#19)
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.
1 parent 7b07e23 commit 43da95c

File tree

8 files changed

+362
-41
lines changed

8 files changed

+362
-41
lines changed

pkgs/sdk/server/contract-tests/TestService.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ public class Webapp
3737
"tags",
3838
"inline-context",
3939
"anonymous-redaction",
40-
"evaluation-hooks"
40+
"evaluation-hooks",
41+
"client-prereq-events"
4142
};
4243

4344
public readonly Handler Handler;

pkgs/sdk/server/src/FeatureFlagsState.cs

Lines changed: 92 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ public FeatureFlagsState Build()
156156
/// </summary>
157157
/// <param name="valid">true if valid, false if invalid (default is valid)</param>
158158
/// <returns>the same builder</returns>
159+
[Obsolete("Unused, construct a FeatureFlagState with valid/invalid state directly")]
159160
public FeatureFlagsStateBuilder Valid(bool valid)
160161
{
161162
_valid = valid;
@@ -167,8 +168,21 @@ public FeatureFlagsStateBuilder Valid(bool valid)
167168
/// </summary>
168169
/// <param name="flagKey">the flag key</param>
169170
/// <param name="result">the evaluation result</param>
170-
/// <returns></returns>
171+
/// <returns>the same builder</returns>
171172
public FeatureFlagsStateBuilder AddFlag(string flagKey, EvaluationDetail<LdValue> result)
173+
{
174+
return AddFlag(flagKey, result, new List<string>());
175+
}
176+
177+
178+
/// <summary>
179+
/// Adds the result of a flag evaluation, including direct prerequisites.
180+
/// </summary>
181+
/// <param name="flagKey">the flag key</param>
182+
/// <param name="result">the evaluation result</param>
183+
/// <param name="prerequisites">the direct prerequisites evaluated for this flag</param>
184+
/// <returns>the same builder</returns>
185+
public FeatureFlagsStateBuilder AddFlag(string flagKey, EvaluationDetail<LdValue> result, List<string> prerequisites)
172186
{
173187
return AddFlag(flagKey,
174188
result.Value,
@@ -177,13 +191,14 @@ public FeatureFlagsStateBuilder AddFlag(string flagKey, EvaluationDetail<LdValue
177191
0,
178192
false,
179193
false,
180-
null);
194+
null,
195+
prerequisites);
181196
}
182197

183198
// This method is defined with internal scope because metadata fields like trackEvents aren't
184199
// relevant to the main external use case for the builder (testing server-side code)
185200
internal FeatureFlagsStateBuilder AddFlag(string flagKey, LdValue value, int? variationIndex, EvaluationReason reason,
186-
int flagVersion, bool flagTrackEvents, bool trackReason, UnixMillisecondTime? flagDebugEventsUntilDate)
201+
int flagVersion, bool flagTrackEvents, bool trackReason, UnixMillisecondTime? flagDebugEventsUntilDate, List<string> prerequisites)
187202
{
188203
bool flagIsTracked = flagTrackEvents || flagDebugEventsUntilDate != null;
189204
var flag = new FlagState
@@ -194,14 +209,15 @@ internal FeatureFlagsStateBuilder AddFlag(string flagKey, LdValue value, int? va
194209
Reason = trackReason || (_withReasons && (!_detailsOnlyIfTracked || flagIsTracked)) ? reason : (EvaluationReason?)null,
195210
DebugEventsUntilDate = flagDebugEventsUntilDate,
196211
TrackEvents = flagTrackEvents,
197-
TrackReason = trackReason
212+
TrackReason = trackReason,
213+
Prerequisites = prerequisites
198214
};
199215
_flags[flagKey] = flag;
200216
return this;
201217
}
202218
}
203219

204-
internal struct FlagState
220+
internal struct FlagState : IEquatable<FlagState>
205221
{
206222
internal LdValue Value { get; set; }
207223
internal int? Variation { get; set; }
@@ -211,24 +227,38 @@ internal struct FlagState
211227
internal UnixMillisecondTime? DebugEventsUntilDate { get; set; }
212228
internal EvaluationReason? Reason { get; set; }
213229

214-
public override bool Equals(object other)
230+
internal IReadOnlyList<string> Prerequisites { get; set; }
231+
232+
233+
public bool Equals(FlagState o)
215234
{
216-
if (other is FlagState o)
217-
{
218-
return Variation == o.Variation &&
219-
Version == o.Version &&
220-
TrackEvents == o.TrackEvents &&
221-
TrackReason == o.TrackReason &&
222-
DebugEventsUntilDate.Equals(o.DebugEventsUntilDate) &&
223-
Object.Equals(Reason, o.Reason);
224-
}
225-
return false;
235+
return Variation == o.Variation &&
236+
Version == o.Version &&
237+
TrackEvents == o.TrackEvents &&
238+
TrackReason == o.TrackReason &&
239+
DebugEventsUntilDate.Equals(o.DebugEventsUntilDate) &&
240+
Object.Equals(Reason, o.Reason) &&
241+
Prerequisites.SequenceEqual(o.Prerequisites);
242+
}
243+
public override bool Equals(object obj)
244+
{
245+
return obj is FlagState other && Equals(other);
246+
}
247+
248+
public static bool operator ==(FlagState lhs, FlagState rhs)
249+
{
250+
return lhs.Equals(rhs);
251+
}
252+
253+
public static bool operator !=(FlagState lhs, FlagState rhs)
254+
{
255+
return !(lhs == rhs);
226256
}
227257

228258
public override int GetHashCode()
229259
{
230-
return new HashCodeBuilder().With(Variation).With(Version).With(TrackEvents).With(TrackReason).
231-
With(DebugEventsUntilDate).With(Reason).Value;
260+
return new HashCodeBuilder().With(Variation).With(Version).With(TrackEvents).With(TrackReason)
261+
.With(DebugEventsUntilDate).With(Reason).With(Prerequisites).Value;
232262
}
233263
}
234264

@@ -271,6 +301,14 @@ public override void Write(Utf8JsonWriter w, FeatureFlagsState state, JsonSerial
271301
w.WritePropertyName("reason");
272302
EvaluationReasonConverter.WriteJsonValue(meta.Reason.Value, w);
273303
}
304+
if (meta.Prerequisites.Count > 0) {
305+
w.WriteStartArray("prerequisites");
306+
foreach (var p in meta.Prerequisites)
307+
{
308+
w.WriteStringValue(p);
309+
}
310+
w.WriteEndArray();
311+
}
274312
w.WriteEndObject();
275313
}
276314
w.WriteEndObject();
@@ -282,6 +320,7 @@ public override FeatureFlagsState Read(ref Utf8JsonReader reader, Type typeToCon
282320
{
283321
var valid = true;
284322
var flags = new Dictionary<string, FlagState>();
323+
285324
for (var topLevelObj = RequireObject(ref reader); topLevelObj.Next(ref reader);)
286325
{
287326
var key = topLevelObj.Name;
@@ -295,7 +334,15 @@ public override FeatureFlagsState Read(ref Utf8JsonReader reader, Type typeToCon
295334
for (var flagsObj = RequireObject(ref reader); flagsObj.Next(ref reader);)
296335
{
297336
var subKey = flagsObj.Name;
298-
var flag = flags.ContainsKey(subKey) ? flags[subKey] : new FlagState();
337+
338+
var flag = flags.ContainsKey(subKey)
339+
? flags[subKey]
340+
: new FlagState
341+
{
342+
// Most flags have no prerequisites, don't allocate capacity unless we need to.
343+
Prerequisites = new List<string>(0)
344+
};
345+
299346
for (var metaObj = RequireObject(ref reader); metaObj.Next(ref reader);)
300347
{
301348
switch (metaObj.Name)
@@ -318,14 +365,39 @@ public override FeatureFlagsState Read(ref Utf8JsonReader reader, Type typeToCon
318365
flag.Reason = reader.TokenType == JsonTokenType.Null ? (EvaluationReason?)null :
319366
EvaluationReasonConverter.ReadJsonValue(ref reader);
320367
break;
368+
case "prerequisites":
369+
// Note: there is an assumption in this code that a given flag key could already
370+
// have been seen before: specifically in the "values" section of the data
371+
// (where it's a simple map of flag key -> evaluated value), but *also* if we
372+
// have duplicate flag keys under the $flagState key.
373+
//
374+
// The first case is expected, but the second is not. LaunchDarkly SaaS / SDKs
375+
// should never generate JSON that has duplicate keys. If this did happen,
376+
// we don't want to 'merge' prerequisites in an arbitrary order: it's important
377+
// that they remain the order they were serialized originally.
378+
//
379+
// Therefore, the behavior here is that the last seen value for a key will 'win'
380+
// and overwrite any previous value.
381+
var prereqList = new List<string>();
382+
for (var prereqs = RequireArray(ref reader); prereqs.Next(ref reader);)
383+
{
384+
prereqList.Add(reader.GetString());
385+
386+
}
387+
flag.Prerequisites = prereqList;
388+
break;
321389
}
322390
}
323391
flags[subKey] = flag;
324392
}
325393
break;
326394

327395
default:
328-
var flagForValue = flags.ContainsKey(key) ? flags[key] : new FlagState();
396+
var flagForValue = flags.ContainsKey(key) ? flags[key] : new FlagState
397+
{
398+
// Most flags have no prerequisites, don't allocate capacity unless we need to.
399+
Prerequisites = new List<string>(0)
400+
};
329401
flagForValue.Value = LdValueConverter.ReadJsonValue(ref reader);
330402
flags[key] = flagForValue;
331403
break;

pkgs/sdk/server/src/Internal/Evaluation/EvaluatorTypes.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,14 @@ internal EvalResult(EvaluationDetail<LdValue> result, IList<PrerequisiteEvalReco
2121
internal struct PrerequisiteEvalRecord
2222
{
2323
internal readonly FeatureFlag PrerequisiteFlag;
24-
internal readonly string PrerequisiteOfFlagKey;
24+
internal readonly string FlagKey;
2525
internal readonly EvaluationDetail<LdValue> Result;
2626

27-
internal PrerequisiteEvalRecord(FeatureFlag prerequisiteFlag, string prerequisiteOfFlagKey,
27+
internal PrerequisiteEvalRecord(FeatureFlag prerequisiteFlag, string flagKey,
2828
EvaluationDetail<LdValue> result)
2929
{
3030
PrerequisiteFlag = prerequisiteFlag;
31-
PrerequisiteOfFlagKey = prerequisiteOfFlagKey;
31+
FlagKey = flagKey;
3232
Result = result;
3333
}
3434
}

pkgs/sdk/server/src/LdClient.cs

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Collections.Generic;
23
using System.Linq;
34
using System.Security.Cryptography;
45
using LaunchDarkly.Logging;
@@ -371,8 +372,7 @@ public FeatureFlagsState AllFlagsState(Context context, params FlagsStateOption[
371372

372373
var builder = new FeatureFlagsStateBuilder(options);
373374
var clientSideOnly = FlagsStateOption.HasOption(options, FlagsStateOption.ClientSideOnly);
374-
var withReasons = FlagsStateOption.HasOption(options, FlagsStateOption.WithReasons);
375-
var detailsOnlyIfTracked = FlagsStateOption.HasOption(options, FlagsStateOption.DetailsOnlyForTrackedFlags);
375+
376376
KeyedItems<ItemDescriptor> flags;
377377
try
378378
{
@@ -397,6 +397,11 @@ public FeatureFlagsState AllFlagsState(Context context, params FlagsStateOption[
397397
{
398398
EvaluatorTypes.EvalResult result = _evaluator.Evaluate(flag, context);
399399
bool inExperiment = EventFactory.IsExperiment(flag, result.Result.Reason);
400+
401+
var directPrerequisites = result.PrerequisiteEvals.Where(
402+
e => e.FlagKey == flag.Key)
403+
.Select(p => p.PrerequisiteFlag.Key).ToList();
404+
400405
builder.AddFlag(
401406
flag.Key,
402407
result.Result.Value,
@@ -405,16 +410,16 @@ public FeatureFlagsState AllFlagsState(Context context, params FlagsStateOption[
405410
flag.Version,
406411
flag.TrackEvents || inExperiment,
407412
inExperiment,
408-
flag.DebugEventsUntilDate
409-
);
413+
flag.DebugEventsUntilDate,
414+
directPrerequisites);
410415
}
411416
catch (Exception e)
412417
{
413418
LogHelpers.LogException(_evalLog,
414419
string.Format("Exception caught for feature flag \"{0}\" when evaluating all flags", flag.Key),
415420
e);
416421
EvaluationReason reason = EvaluationReason.ErrorReason(EvaluationErrorKind.Exception);
417-
builder.AddFlag(flag.Key, new EvaluationDetail<LdValue>(LdValue.Null, null, reason));
422+
builder.AddFlag(flag.Key, new EvaluationDetail<LdValue>(LdValue.Null, null, reason), new List<string>());
418423
}
419424
}
420425
return builder.Build();
@@ -477,7 +482,7 @@ public FeatureFlagsState AllFlagsState(Context context, params FlagsStateOption[
477482
foreach (var prereqEvent in evalResult.PrerequisiteEvals)
478483
{
479484
_eventProcessor.RecordEvaluationEvent(eventFactory.NewPrerequisiteEvaluationEvent(
480-
prereqEvent.PrerequisiteFlag, context, prereqEvent.Result, prereqEvent.PrerequisiteOfFlagKey));
485+
prereqEvent.PrerequisiteFlag, context, prereqEvent.Result, prereqEvent.FlagKey));
481486
}
482487
}
483488
var evalDetail = evalResult.Result;

pkgs/sdk/server/test/FeatureFlagsStateTest.cs

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,13 @@ public void CanConvertToValuesMap()
7171
public void CanSerializeToJson()
7272
{
7373
var state = FeatureFlagsState.Builder(FlagsStateOption.WithReasons)
74-
.AddFlag("key1", LdValue.Of("value1"), 0, EvaluationReason.OffReason, 100, false, false, null)
75-
.AddFlag("key2", LdValue.Of("value2"), 1, EvaluationReason.FallthroughReason, 200, true, false, UnixMillisecondTime.OfMillis(1000))
76-
.AddFlag("key3", LdValue.Null, null, EvaluationReason.ErrorReason(EvaluationErrorKind.MalformedFlag), 300, false, false, null)
74+
.AddFlag("key1", LdValue.Of("value1"), 0, EvaluationReason.OffReason, 100, false, false, null,
75+
new List<string>())
76+
.AddFlag("key2", LdValue.Of("value2"), 1, EvaluationReason.FallthroughReason, 200, true, false,
77+
UnixMillisecondTime.OfMillis(1000), new List<string>())
78+
.AddFlag("key3", LdValue.Null, null, EvaluationReason.ErrorReason(EvaluationErrorKind.MalformedFlag),
79+
300, false, false, null, new List<string>()
80+
)
7781
.Build();
7882

7983
var expectedString = @"{""key1"":""value1"",""key2"":""value2"",""key3"":null,
@@ -92,18 +96,63 @@ public void CanSerializeToJson()
9296
JsonAssertions.AssertJsonEqual(expectedString, actualString);
9397
}
9498

99+
[Fact]
100+
public void CanSerializeFlagPrerequisites()
101+
{
102+
var state = FeatureFlagsState.Builder(FlagsStateOption.WithReasons)
103+
.AddFlag("prereq1", LdValue.Of("value1"), 0, EvaluationReason.OffReason, 100, false, false, null,
104+
new List<string>())
105+
.AddFlag("prereq2", LdValue.Of("value2"), 1, EvaluationReason.FallthroughReason, 200, true, false,
106+
UnixMillisecondTime.OfMillis(1000), new List<string>())
107+
.AddFlag("toplevel", LdValue.Null, null,
108+
EvaluationReason.ErrorReason(EvaluationErrorKind.MalformedFlag), 300, false, false, null,
109+
new List<string>
110+
{
111+
"prereq1", "prereq2"
112+
})
113+
.Build();
114+
115+
116+
var expectedString = @"{""prereq1"":""value1"",""prereq2"":""value2"",""toplevel"":null,
117+
""$flagsState"":{
118+
""prereq1"":{
119+
""variation"":0,""version"":100,""reason"":{""kind"":""OFF""}
120+
},""prereq2"":{
121+
""variation"":1,""version"":200,""reason"":{""kind"":""FALLTHROUGH""},""trackEvents"":true,""debugEventsUntilDate"":1000
122+
},""toplevel"":{
123+
""version"":300,""reason"":{""kind"":""ERROR"",""errorKind"":""MALFORMED_FLAG""},""prerequisites"":[""prereq1"",""prereq2""]
124+
}
125+
},
126+
""$valid"":true
127+
}";
128+
var actualString = LdJsonSerialization.SerializeObject(state);
129+
JsonAssertions.AssertJsonEqual(expectedString, actualString);
130+
}
131+
132+
95133
[Fact]
96134
public void CanDeserializeFromJson()
97135
{
98136
var state = FeatureFlagsState.Builder(FlagsStateOption.WithReasons)
99-
.AddFlag("key1", LdValue.Of("value1"), 0, EvaluationReason.OffReason, 100, false, false, null)
100-
.AddFlag("key2", LdValue.Of("value2"), 1, EvaluationReason.FallthroughReason, 200, true, false, UnixMillisecondTime.OfMillis(1000))
137+
.AddFlag("key1", LdValue.Of("value1"), 0, EvaluationReason.OffReason, 100, false, false, null,
138+
new List<string>())
139+
.AddFlag("key2", LdValue.Of("value2"), 1, EvaluationReason.FallthroughReason, 200, true, false,
140+
UnixMillisecondTime.OfMillis(1000), new List<string> { "key1" })
101141
.Build();
102142

103143
var jsonString = LdJsonSerialization.SerializeObject(state);
104144
var state1 = LdJsonSerialization.DeserializeObject<FeatureFlagsState>(jsonString);
105145

146+
var jsonString2 = LdJsonSerialization.SerializeObject(state1);
147+
148+
// Ensure a roundtrip state -> json -> json is equal.
149+
Assert.Equal(jsonString, jsonString2);
150+
151+
// Ensure a roundtrip state -> json -> state is equal.
106152
Assert.Equal(state, state1);
107153
}
108154
}
109-
}
155+
156+
157+
158+
}

0 commit comments

Comments
 (0)