From fc225dc5c0933c546a16c1db191b752df5ed8b91 Mon Sep 17 00:00:00 2001 From: Eli Bishop <35503443+eli-darkly@users.noreply.github.com> Date: Thu, 30 Aug 2018 15:22:13 -0700 Subject: [PATCH] prepare 5.4.0 release (#90) --- CHANGELOG.md | 7 + src/LaunchDarkly.Client/FeatureFlag.cs | 254 ++++----- src/LaunchDarkly.Client/FeatureFlagsState.cs | 31 +- src/LaunchDarkly.Client/FlagsStateOption.cs | 7 + src/LaunchDarkly.Client/ILdClient.cs | 55 ++ .../LaunchDarkly.Client.csproj | 4 +- src/LaunchDarkly.Client/LdClient.cs | 133 +++-- src/LaunchDarkly.Client/Rule.cs | 6 +- test/LaunchDarkly.Tests/FeatureFlagBuilder.cs | 8 +- test/LaunchDarkly.Tests/FeatureFlagTest.cs | 531 ++++++++++++++++++ .../FeatureFlagsStateTest.cs | 44 +- .../JsonSerializationTest.cs | 3 +- .../LaunchDarkly.Tests.csproj | 2 +- .../LdClientEvaluationTest.cs | 138 +++++ 14 files changed, 1031 insertions(+), 192 deletions(-) create mode 100644 test/LaunchDarkly.Tests/FeatureFlagTest.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 29b2242c..8d7f2cf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to the LaunchDarkly .NET SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [5.4.0] - 2018-08-30 +### Added: +- The new `LDClient` methods `BoolVariationDetail`, `IntVariationDetail`, `DoubleVariationDetail`, `StringVariationDetail`, and `JsonVariationDetail` allow you to evaluate a feature flag (using the same parameters as you would for `BoolVariation`, etc.) and receive more information about how the value was calculated. This information is returned in an `EvaluationDetail` object, which contains both the result value and an `EvaluationReason` which will tell you, for instance, if the user was individually targeted for the flag or was matched by one of the flag's rules, or if the flag returned the default value due to an error. + +### Fixed: +- When evaluating a prerequisite feature flag, the analytics event for the evaluation did not include the result value if the prerequisite flag was off. + ## [5.3.1] - 2018-08-30 ### Fixed: - Fixed a bug in streaming mode that prevented the client from reconnecting to the stream if it received an HTTP error status from the server (as opposed to simply losing the connection). ([#88](https://github.com/launchdarkly/dotnet-client/issues/88)) diff --git a/src/LaunchDarkly.Client/FeatureFlag.cs b/src/LaunchDarkly.Client/FeatureFlag.cs index b3c9829c..1a3cde21 100644 --- a/src/LaunchDarkly.Client/FeatureFlag.cs +++ b/src/LaunchDarkly.Client/FeatureFlag.cs @@ -68,13 +68,11 @@ internal FeatureFlag() internal struct EvalResult { - internal int? Variation; - internal JToken Result; + internal EvaluationDetail Result; internal readonly IList PrerequisiteEvents; - internal EvalResult(int? variation, JToken result, IList events) : this() + internal EvalResult(EvaluationDetail result, IList events) : this() { - Variation = variation; Result = result; PrerequisiteEvents = events; } @@ -83,149 +81,141 @@ internal EvalResult(int? variation, JToken result, IList ev internal EvalResult Evaluate(User user, IFeatureStore featureStore, EventFactory eventFactory) { IList prereqEvents = new List(); - EvalResult evalResult = new EvalResult(null, null, prereqEvents); if (user == null || user.Key == null) { Log.WarnFormat("User or user key is null when evaluating flag: {0} returning null", Key); - return evalResult; - } - - if (On) - { - evalResult = Evaluate(user, featureStore, prereqEvents, eventFactory); - if (evalResult.Result != null) - { - return evalResult; - } - } - evalResult.Variation = OffVariation; - evalResult.Result = OffVariationValue; - return evalResult; + return new EvalResult( + new EvaluationDetail(null, null, new EvaluationReason.Error(EvaluationErrorKind.USER_NOT_SPECIFIED)), + prereqEvents); + } + var details = Evaluate(user, featureStore, prereqEvents, eventFactory); + return new EvalResult(details, prereqEvents); } - // Returning either a nil EvalResult or EvalResult.value indicates prereq failure/error. - private EvalResult Evaluate(User user, IFeatureStore featureStore, IList events, + private EvaluationDetail Evaluate(User user, IFeatureStore featureStore, IList events, EventFactory eventFactory) { - var prereqOk = true; - if (Prerequisites != null) - { - foreach (var prereq in Prerequisites) - { - var prereqFeatureFlag = featureStore.Get(VersionedDataKind.Features, prereq.Key); - EvalResult prereqEvalResult = new EvalResult(null, null, events); - if (prereqFeatureFlag == null) - { - Log.ErrorFormat("Could not retrieve prerequisite flag: {0} when evaluating: {1}", - prereq.Key, - Key); - return new EvalResult(null, null, events); - } - else if (prereqFeatureFlag.On) - { - prereqEvalResult = prereqFeatureFlag.Evaluate(user, featureStore, events, eventFactory); - try - { - if (prereqEvalResult.Variation != prereq.Variation) - { - prereqOk = false; - } - } - catch (EvaluationException e) - { - Log.WarnFormat("Error evaluating prerequisites: {0}", - e, - Util.ExceptionMessage(e)); - - prereqOk = false; - } - } - else - { - prereqOk = false; - } - //We don't short circuit and also send events for each prereq. - events.Add(eventFactory.NewPrerequisiteFeatureRequestEvent(prereqFeatureFlag, - user, null, prereqEvalResult.Result, this)); - } - } - if (prereqOk) - { - int? index = EvaluateIndex(user, featureStore); - JToken result = GetVariation(index); - return new EvalResult(index, result, events); - } - return new EvalResult(null, null, events); - } - - private int? EvaluateIndex(User user, IFeatureStore store) - { - // Check to see if targets match - foreach (var target in Targets) - { - foreach (var v in target.Values) - { - if (v.Equals(user.Key)) - { - return target.Variation; - } - } - } - - // Now walk through the rules and see if any match - foreach (Rule rule in Rules) - { - if (rule.MatchesUser(user, store)) - { - return rule.VariationIndexForUser(user, Key, Salt); - } + if (!On) + { + return GetOffValue(EvaluationReason.Off.Instance); } - // Walk through the fallthrough and see if it matches - return Fallthrough.VariationIndexForUser(user, Key, Salt); - } - - private JToken GetVariation(int? index) + var prereqFailureReason = CheckPrerequisites(user, featureStore, events, eventFactory); + if (prereqFailureReason != null) + { + return GetOffValue(prereqFailureReason); + } + + // Check to see if targets match + if (Targets != null) + { + foreach (var target in Targets) + { + foreach (var v in target.Values) + { + if (user.Key == v) + { + return GetVariation(target.Variation, EvaluationReason.TargetMatch.Instance); + } + } + } + } + // Now walk through the rules and see if any match + if (Rules != null) + { + for (int i = 0; i < Rules.Count; i++) + { + Rule rule = Rules[i]; + if (rule.MatchesUser(user, featureStore)) + { + return GetValueForVariationOrRollout(rule, user, + new EvaluationReason.RuleMatch(i, rule.Id)); + } + } + } + // Walk through the fallthrough and see if it matches + return GetValueForVariationOrRollout(Fallthrough, user, EvaluationReason.Fallthrough.Instance); + } + + // Checks prerequisites if any; returns null if successful, or an EvaluationReason if we have to + // short-circuit due to a prerequisite failure. + private EvaluationReason CheckPrerequisites(User user, IFeatureStore featureStore, IList events, + EventFactory eventFactory) { - // If the supplied index is null, then rules didn't match, and we want to return - // the off variation - if (index == null) - { - return null; - } - // If the index doesn't refer to a valid variation, that's an unexpected exception and we will - // return the default variation - else if (index >= Variations.Count) - { - throw new EvaluationException("Invalid index"); + if (Prerequisites == null || Prerequisites.Count == 0) + { + return null; } - else - { - return Variations[index.Value]; + foreach (var prereq in Prerequisites) + { + var prereqOk = true; + var prereqFeatureFlag = featureStore.Get(VersionedDataKind.Features, prereq.Key); + if (prereqFeatureFlag == null) + { + Log.ErrorFormat("Could not retrieve prerequisite flag \"{0}\" when evaluating \"{1}\"", + prereq.Key, Key); + prereqOk = false; + } + else + { + var prereqEvalResult = prereqFeatureFlag.Evaluate(user, featureStore, events, eventFactory); + // Note that if the prerequisite flag is off, we don't consider it a match no matter + // what its off variation was. But we still need to evaluate it in order to generate + // an event. + if (!prereqFeatureFlag.On || prereqEvalResult.VariationIndex == null || prereqEvalResult.VariationIndex.Value != prereq.Variation) + { + prereqOk = false; + } + events.Add(eventFactory.NewPrerequisiteFeatureRequestEvent(prereqFeatureFlag, user, + prereqEvalResult, this)); + } + if (!prereqOk) + { + return new EvaluationReason.PrerequisiteFailed(prereq.Key); + } } + return null; + } + + internal EvaluationDetail ErrorResult(EvaluationErrorKind kind) + { + return new EvaluationDetail(null, null, new EvaluationReason.Error(kind)); + } + + internal EvaluationDetail GetVariation(int variation, EvaluationReason reason) + { + if (variation < 0 || variation >= Variations.Count) + { + Log.ErrorFormat("Data inconsistency in feature flag \"{0}\": invalid variation index", Key); + return ErrorResult(EvaluationErrorKind.MALFORMED_FLAG); + } + return new EvaluationDetail(Variations[variation], variation, reason); + } + + internal EvaluationDetail GetOffValue(EvaluationReason reason) + { + if (OffVariation == null) // off variation unspecified - return default value + { + return new EvaluationDetail(null, null, reason); + } + return GetVariation(OffVariation.Value, reason); + } + + internal EvaluationDetail GetValueForVariationOrRollout(VariationOrRollout vr, + User user, EvaluationReason reason) + { + var index = vr.VariationIndexForUser(user, Key, Salt); + if (index == null) + { + Log.ErrorFormat("Data inconsistency in feature flag \"{0}\": variation/rollout object with no variation or rollout", Key); + return ErrorResult(EvaluationErrorKind.MALFORMED_FLAG); + } + return GetVariation(index.Value, reason); } - - internal JToken OffVariationValue - { - get - { - if (!OffVariation.HasValue) - { - return null; - } - - if (OffVariation.Value >= Variations.Count) - { - throw new EvaluationException("Invalid off variation index"); - } - - return Variations[OffVariation.Value]; - } - } - } - + } + internal class Rollout { [JsonProperty(PropertyName = "variations")] diff --git a/src/LaunchDarkly.Client/FeatureFlagsState.cs b/src/LaunchDarkly.Client/FeatureFlagsState.cs index 5139e706..19d165fe 100644 --- a/src/LaunchDarkly.Client/FeatureFlagsState.cs +++ b/src/LaunchDarkly.Client/FeatureFlagsState.cs @@ -41,7 +41,7 @@ internal FeatureFlagsState(bool valid, IDictionary values, _flagMetadata = metadata; } - internal void AddFlag(FeatureFlag flag, JToken value, int? variation) + internal void AddFlag(FeatureFlag flag, JToken value, int? variation, EvaluationReason reason) { _flagValues[flag.Key] = value; _flagMetadata[flag.Key] = new FlagMetadata @@ -49,7 +49,8 @@ internal void AddFlag(FeatureFlag flag, JToken value, int? variation) Variation = variation, Version = flag.Version, TrackEvents = flag.TrackEvents, - DebugEventsUntilDate = flag.DebugEventsUntilDate + DebugEventsUntilDate = flag.DebugEventsUntilDate, + Reason = reason }; } @@ -68,6 +69,23 @@ public JToken GetFlagValue(string key) return null; } + /// + /// Returns the evaluation reason of an individual feature flag (as returned by + /// , etc.) at the time the state + /// was recorded. + /// + /// the feature flag key + /// the evaluation reason; null if reasons were not recorded, or if there was no + /// such flag + public EvaluationReason GetFlagReason(string key) + { + if (_flagMetadata.TryGetValue(key, out var meta)) + { + return meta.Reason; + } + return null; + } + /// /// Returns a dictionary of flag keys to flag values. If a flag would have evaluated to the /// default value, its value will be null. @@ -111,6 +129,8 @@ internal class FlagMetadata internal bool TrackEvents { get; set; } [JsonProperty(PropertyName = "debugEventsUntilDate", NullValueHandling = NullValueHandling.Ignore)] internal long? DebugEventsUntilDate { get; set; } + [JsonProperty(PropertyName = "reason", NullValueHandling = NullValueHandling.Ignore)] + internal EvaluationReason Reason { get; set; } public override bool Equals(object other) { @@ -119,15 +139,16 @@ public override bool Equals(object other) return Variation == o.Variation && Version == o.Version && TrackEvents == o.TrackEvents && - DebugEventsUntilDate == o.DebugEventsUntilDate; + DebugEventsUntilDate == o.DebugEventsUntilDate && + Object.Equals(Reason, o.Reason); } return false; } public override int GetHashCode() { - return ((((Variation.GetHashCode() * 17) + Version.GetHashCode()) * 17) + TrackEvents.GetHashCode()) * 17 + - DebugEventsUntilDate.GetHashCode(); + return (((((Variation.GetHashCode() * 17) + Version.GetHashCode()) * 17) + TrackEvents.GetHashCode()) * 17 + + DebugEventsUntilDate.GetHashCode()) * 17 + (Reason == null ? 0 : Reason.GetHashCode()); } } diff --git a/src/LaunchDarkly.Client/FlagsStateOption.cs b/src/LaunchDarkly.Client/FlagsStateOption.cs index 089d1c5a..da618b38 100644 --- a/src/LaunchDarkly.Client/FlagsStateOption.cs +++ b/src/LaunchDarkly.Client/FlagsStateOption.cs @@ -31,6 +31,13 @@ public override string ToString() /// public static readonly FlagsStateOption ClientSideOnly = new FlagsStateOption("ClientSideOnly"); + /// + /// Specifies that evaluation reasons should be included in the state object (as returned by + /// , etc.). By default, they + /// are not included. + /// + public static readonly FlagsStateOption WithReasons = new FlagsStateOption("WithReasons"); + internal static bool HasOption(FlagsStateOption[] options, FlagsStateOption option) { foreach (var o in options) diff --git a/src/LaunchDarkly.Client/ILdClient.cs b/src/LaunchDarkly.Client/ILdClient.cs index 82947962..a331c81b 100644 --- a/src/LaunchDarkly.Client/ILdClient.cs +++ b/src/LaunchDarkly.Client/ILdClient.cs @@ -26,6 +26,17 @@ public interface ILdClient : ILdCommonClient /// disabled in the LaunchDarkly control panel int IntVariation(string key, User user, int defaultValue); + /// + /// Calculates the integer value of a feature flag for a given user, and returns an object that + /// describes the way the value was determined. The Reason property in the result will + /// also be included in analytics events, if you are capturing detailed event data for this flag. + /// + /// the unique feature key for the feature flag + /// the end user requesting the flag + /// the default value of the flag + /// an EvaluationDetail object + EvaluationDetail IntVariationDetail(string key, User user, int defaultValue); + /// /// Calculates the floating point numeric value of a feature flag for a given user. /// @@ -36,6 +47,17 @@ public interface ILdClient : ILdCommonClient /// disabled in the LaunchDarkly control panel float FloatVariation(string key, User user, float defaultValue); + /// + /// Calculates the floating point numeric value of a feature flag for a given user, and returns an object that + /// describes the way the value was determined. The Reason property in the result will + /// also be included in analytics events, if you are capturing detailed event data for this flag. + /// + /// the unique feature key for the feature flag + /// the end user requesting the flag + /// the default value of the flag + /// an EvaluationDetail object + EvaluationDetail FloatVariationDetail(string key, User user, float defaultValue); + /// /// Calculates the value of a feature flag for a given user. /// @@ -46,6 +68,17 @@ public interface ILdClient : ILdCommonClient /// disabled in the LaunchDarkly control panel JToken JsonVariation(string key, User user, JToken defaultValue); + /// + /// Calculates the value of a feature flag for a given user, and returns an object that + /// describes the way the value was determined. The Reason property in the result will + /// also be included in analytics events, if you are capturing detailed event data for this flag. + /// + /// the unique feature key for the feature flag + /// the end user requesting the flag + /// the default value of the flag + /// an EvaluationDetail object + EvaluationDetail JsonVariationDetail(string key, User user, JToken defaultValue); + /// /// Calculates the string value of a feature flag for a given user. /// @@ -56,6 +89,17 @@ public interface ILdClient : ILdCommonClient /// disabled in the LaunchDarkly control panel string StringVariation(string key, User user, string defaultValue); + /// + /// Calculates the string value of a feature flag for a given user, and returns an object that + /// describes the way the value was determined. The Reason property in the result will + /// also be included in analytics events, if you are capturing detailed event data for this flag. + /// + /// the unique feature key for the feature flag + /// the end user requesting the flag + /// the default value of the flag + /// an EvaluationDetail object + EvaluationDetail StringVariationDetail(string key, User user, string defaultValue); + /// /// Calculates the value of a feature flag for a given user. /// @@ -66,6 +110,17 @@ public interface ILdClient : ILdCommonClient /// disabled in the LaunchDarkly control panel bool BoolVariation(string key, User user, bool defaultValue = false); + /// + /// Calculates the value of a feature flag for a given user, and returns an object that + /// describes the way the value was determined. The Reason property in the result will + /// also be included in analytics events, if you are capturing detailed event data for this flag. + /// + /// the unique feature key for the feature flag + /// the end user requesting the flag + /// the default value of the flag + /// an EvaluationDetail object + EvaluationDetail BoolVariationDetail(string key, User user, bool defaultValue); + /// /// Registers the user. /// diff --git a/src/LaunchDarkly.Client/LaunchDarkly.Client.csproj b/src/LaunchDarkly.Client/LaunchDarkly.Client.csproj index a0a14532..d2b33a03 100644 --- a/src/LaunchDarkly.Client/LaunchDarkly.Client.csproj +++ b/src/LaunchDarkly.Client/LaunchDarkly.Client.csproj @@ -1,6 +1,6 @@  - 5.3.1 + 5.4.0 netstandard1.4;netstandard1.6;netstandard2.0;net45 https://raw.githubusercontent.com/launchdarkly/.net-client/master/LICENSE portable @@ -15,7 +15,7 @@ false - + diff --git a/src/LaunchDarkly.Client/LdClient.cs b/src/LaunchDarkly.Client/LdClient.cs index 1121f778..aeff684c 100644 --- a/src/LaunchDarkly.Client/LdClient.cs +++ b/src/LaunchDarkly.Client/LdClient.cs @@ -20,7 +20,6 @@ public sealed class LdClient : IDisposable, ILdClient internal readonly IEventProcessor _eventProcessor; private readonly IFeatureStore _featureStore; internal readonly IUpdateProcessor _updateProcessor; - private readonly EventFactory _eventFactory = EventFactory.Default; private bool _shouldDisposeEventProcessor; private bool _shouldDisposeFeatureStore; @@ -129,38 +128,72 @@ public bool IsOffline() /// public bool BoolVariation(string key, User user, bool defaultValue = false) { - var value = Evaluate(key, user, defaultValue, JTokenType.Boolean); + var value = Evaluate(key, user, defaultValue, JTokenType.Boolean, EventFactory.Default).Value; return value.Value(); } /// public int IntVariation(string key, User user, int defaultValue) { - var value = Evaluate(key, user, defaultValue, JTokenType.Integer); + var value = Evaluate(key, user, defaultValue, JTokenType.Integer, EventFactory.Default).Value; return value.Value(); } /// public float FloatVariation(string key, User user, float defaultValue) { - var value = Evaluate(key, user, defaultValue, JTokenType.Float); + var value = Evaluate(key, user, defaultValue, JTokenType.Float, EventFactory.Default).Value; return value.Value(); } /// public string StringVariation(string key, User user, string defaultValue) { - var value = Evaluate(key, user, defaultValue, JTokenType.String); + var value = Evaluate(key, user, defaultValue, JTokenType.String, EventFactory.Default).Value; return value.Value(); } /// public JToken JsonVariation(string key, User user, JToken defaultValue) { - var value = Evaluate(key, user, defaultValue, null); + var value = Evaluate(key, user, defaultValue, null, EventFactory.Default).Value; return value; } + /// + public EvaluationDetail BoolVariationDetail(string key, User user, bool defaultValue) + { + var detail = Evaluate(key, user, defaultValue, JTokenType.Boolean, EventFactory.DefaultWithReasons); + return new EvaluationDetail((bool)detail.Value, detail.VariationIndex, detail.Reason); + } + + /// + public EvaluationDetail IntVariationDetail(string key, User user, int defaultValue) + { + var detail = Evaluate(key, user, defaultValue, JTokenType.Integer, EventFactory.DefaultWithReasons); + return new EvaluationDetail((int)detail.Value, detail.VariationIndex, detail.Reason); + } + + /// + public EvaluationDetail FloatVariationDetail(string key, User user, float defaultValue) + { + var detail = Evaluate(key, user, defaultValue, JTokenType.Float, EventFactory.DefaultWithReasons); + return new EvaluationDetail((float)detail.Value, detail.VariationIndex, detail.Reason); + } + + /// + public EvaluationDetail StringVariationDetail(string key, User user, string defaultValue) + { + var detail = Evaluate(key, user, defaultValue, JTokenType.String, EventFactory.DefaultWithReasons); + return new EvaluationDetail((string)detail.Value, detail.VariationIndex, detail.Reason); + } + + /// + public EvaluationDetail JsonVariationDetail(string key, User user, JToken defaultValue) + { + return Evaluate(key, user, defaultValue, null, EventFactory.DefaultWithReasons); + } + /// public IDictionary AllFlags(User user) { @@ -200,6 +233,7 @@ public FeatureFlagsState AllFlagsState(User user, params FlagsStateOption[] opti var state = new FeatureFlagsState(true); var clientSideOnly = FlagsStateOption.HasOption(options, FlagsStateOption.ClientSideOnly); + var withReasons = FlagsStateOption.HasOption(options, FlagsStateOption.WithReasons); IDictionary flags = _featureStore.All(VersionedDataKind.Features); foreach (KeyValuePair pair in flags) { @@ -210,20 +244,23 @@ public FeatureFlagsState AllFlagsState(User user, params FlagsStateOption[] opti } try { - FeatureFlag.EvalResult result = flag.Evaluate(user, _featureStore, _eventFactory); - state.AddFlag(flag, result.Result, result.Variation); + FeatureFlag.EvalResult result = flag.Evaluate(user, _featureStore, EventFactory.Default); + state.AddFlag(flag, result.Result.Value, result.Result.VariationIndex, + withReasons ? result.Result.Reason : null); } catch (Exception e) { Log.ErrorFormat("Exception caught for feature flag \"{0}\" when evaluating all flags: {1}", flag.Key, Util.ExceptionMessage(e)); Log.Debug(e.ToString(), e); - state.AddFlag(flag, null, null); + EvaluationReason reason = new EvaluationReason.Error(EvaluationErrorKind.EXCEPTION); + state.AddFlag(flag, null, null, withReasons ? reason : null); } } return state; } - private JToken Evaluate(string featureKey, User user, JToken defaultValue, JTokenType? expectedType) + private EvaluationDetail Evaluate(string featureKey, User user, JToken defaultValue, JTokenType? expectedType, + EventFactory eventFactory) { if (!Initialized()) { @@ -234,30 +271,36 @@ private JToken Evaluate(string featureKey, User user, JToken defaultValue, JToke else { Log.Warn("Flag evaluation before client initialized; feature store unavailable, returning default value"); - return defaultValue; + return new EvaluationDetail(defaultValue, null, + new EvaluationReason.Error(EvaluationErrorKind.CLIENT_NOT_READY)); } } - + + FeatureFlag featureFlag = null; try { - var featureFlag = _featureStore.Get(VersionedDataKind.Features, featureKey); + featureFlag = _featureStore.Get(VersionedDataKind.Features, featureKey); if (featureFlag == null) { Log.InfoFormat("Unknown feature flag {0}; returning default value", featureKey); - _eventProcessor.SendEvent(_eventFactory.NewUnknownFeatureRequestEvent(featureKey, user, defaultValue)); - return defaultValue; + _eventProcessor.SendEvent(eventFactory.NewUnknownFeatureRequestEvent(featureKey, user, defaultValue, + EvaluationErrorKind.FLAG_NOT_FOUND)); + return new EvaluationDetail(defaultValue, null, + new EvaluationReason.Error(EvaluationErrorKind.FLAG_NOT_FOUND)); } if (user == null || user.Key == null) { Log.Warn("Feature flag evaluation called with null user or null user key. Returning default"); - _eventProcessor.SendEvent(_eventFactory.NewDefaultFeatureRequestEvent(featureFlag, user, defaultValue)); - return defaultValue; + _eventProcessor.SendEvent(eventFactory.NewDefaultFeatureRequestEvent(featureFlag, user, defaultValue, + EvaluationErrorKind.USER_NOT_SPECIFIED)); + return new EvaluationDetail(defaultValue, null, + new EvaluationReason.Error(EvaluationErrorKind.USER_NOT_SPECIFIED)); } - FeatureFlag.EvalResult evalResult = featureFlag.Evaluate(user, _featureStore, _eventFactory); + FeatureFlag.EvalResult evalResult = featureFlag.Evaluate(user, _featureStore, eventFactory); if (!IsOffline()) { foreach (var prereqEvent in evalResult.PrerequisiteEvents) @@ -265,39 +308,47 @@ private JToken Evaluate(string featureKey, User user, JToken defaultValue, JToke _eventProcessor.SendEvent(prereqEvent); } } - if (evalResult.Result != null) + var detail = evalResult.Result; + if (detail.VariationIndex == null) { - if (!CheckResultType(expectedType, evalResult.Result)) - { - Log.ErrorFormat("Expected type: {0} but got {1} when evaluating FeatureFlag: {2}. Returning default", - expectedType, - evalResult.GetType(), - featureKey); - - _eventProcessor.SendEvent(_eventFactory.NewDefaultFeatureRequestEvent(featureFlag, user, defaultValue)); - return defaultValue; - } - _eventProcessor.SendEvent(_eventFactory.NewFeatureRequestEvent(featureFlag, user, evalResult.Variation, evalResult.Result, defaultValue)); - return evalResult.Result; + detail = new EvaluationDetail(defaultValue, null, detail.Reason); } - else + if (detail.Value != null && !CheckResultType(expectedType, detail.Value)) { - _eventProcessor.SendEvent(_eventFactory.NewDefaultFeatureRequestEvent(featureFlag, user, defaultValue)); - return defaultValue; + Log.ErrorFormat("Expected type: {0} but got {1} when evaluating FeatureFlag: {2}. Returning default", + expectedType, + detail.Value.GetType(), + featureKey); + + _eventProcessor.SendEvent(eventFactory.NewDefaultFeatureRequestEvent(featureFlag, user, defaultValue, + EvaluationErrorKind.WRONG_TYPE)); + return new EvaluationDetail(defaultValue, null, + new EvaluationReason.Error(EvaluationErrorKind.WRONG_TYPE)); } + _eventProcessor.SendEvent(eventFactory.NewFeatureRequestEvent(featureFlag, user, detail, defaultValue)); + return detail; } catch (Exception e) { Log.ErrorFormat("Encountered exception in LaunchDarkly client: {0} when evaluating feature key: {1} for user key: {2}", - e, Util.ExceptionMessage(e), featureKey, user.Key); - - Log.Debug("{0}", e); + Log.Debug(e.ToString(), e); + var detail = new EvaluationDetail(defaultValue, null, + new EvaluationReason.Error(EvaluationErrorKind.EXCEPTION)); + if (featureFlag == null) + { + _eventProcessor.SendEvent(eventFactory.NewUnknownFeatureRequestEvent(featureKey, user, defaultValue, + EvaluationErrorKind.EXCEPTION)); + } + else + { + _eventProcessor.SendEvent(eventFactory.NewFeatureRequestEvent(featureFlag, user, + detail, defaultValue)); + } + return detail; } - _eventProcessor.SendEvent(_eventFactory.NewUnknownFeatureRequestEvent(featureKey, user, defaultValue)); - return defaultValue; } private bool CheckResultType(JTokenType? expectedType, JToken result) @@ -351,7 +402,7 @@ public void Track(string name, JToken data, User user) { Log.Warn("Track called with null user or null user key"); } - _eventProcessor.SendEvent(_eventFactory.NewCustomEvent(name, user, data)); + _eventProcessor.SendEvent(EventFactory.Default.NewCustomEvent(name, user, data)); } /// @@ -361,7 +412,7 @@ public void Identify(User user) { Log.Warn("Identify called with null user or null user key"); } - _eventProcessor.SendEvent(_eventFactory.NewIdentifyEvent(user)); + _eventProcessor.SendEvent(EventFactory.Default.NewIdentifyEvent(user)); } /// diff --git a/src/LaunchDarkly.Client/Rule.cs b/src/LaunchDarkly.Client/Rule.cs index 24d8a0bf..1855cb24 100644 --- a/src/LaunchDarkly.Client/Rule.cs +++ b/src/LaunchDarkly.Client/Rule.cs @@ -5,12 +5,16 @@ namespace LaunchDarkly.Client { internal class Rule : VariationOrRollout { + [JsonProperty(PropertyName = "id")] + internal string Id { get; private set; } + [JsonProperty(PropertyName = "clauses")] internal List Clauses { get; private set; } [JsonConstructor] - internal Rule(int? variation, Rollout rollout, List clauses) : base(variation, rollout) + internal Rule(string id, int? variation, Rollout rollout, List clauses) : base(variation, rollout) { + Id = id; Clauses = clauses; } diff --git a/test/LaunchDarkly.Tests/FeatureFlagBuilder.cs b/test/LaunchDarkly.Tests/FeatureFlagBuilder.cs index b29f5bb3..260ea8e7 100644 --- a/test/LaunchDarkly.Tests/FeatureFlagBuilder.cs +++ b/test/LaunchDarkly.Tests/FeatureFlagBuilder.cs @@ -95,6 +95,12 @@ internal FeatureFlagBuilder Fallthrough(VariationOrRollout fallthrough) return this; } + internal FeatureFlagBuilder FallthroughVariation(int variation) + { + _fallthrough = new VariationOrRollout(variation, null); + return this; + } + internal FeatureFlagBuilder OffVariation(int? offVariation) { _offVariation = offVariation; @@ -144,7 +150,7 @@ internal FeatureFlagBuilder BooleanWithClauses(params Clause[] clauses) _on = true; _offVariation = 0; _variations = new List { new JValue(false), new JValue(true) }; - _rules = new List { new Rule(1, null, new List(clauses)) }; + _rules = new List { new Rule("id", 1, null, new List(clauses)) }; return this; } } diff --git a/test/LaunchDarkly.Tests/FeatureFlagTest.cs b/test/LaunchDarkly.Tests/FeatureFlagTest.cs new file mode 100644 index 00000000..ecf88f7c --- /dev/null +++ b/test/LaunchDarkly.Tests/FeatureFlagTest.cs @@ -0,0 +1,531 @@ +using System; +using System.Collections.Generic; +using System.Text; +using LaunchDarkly.Client; +using LaunchDarkly.Common; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace LaunchDarkly.Tests +{ + public class FeatureFlagTest + { + private static readonly User baseUser = User.WithKey("userkey"); + + private readonly IFeatureStore featureStore = new InMemoryFeatureStore(); + + [Fact] + public void FlagReturnsOffVariationIfFlagIsOff() + { + var f = new FeatureFlagBuilder("feature") + .On(false) + .OffVariation(1) + .FallthroughVariation(0) + .Variations(new List { new JValue("fall"), new JValue("off"), new JValue("on") }) + .Build(); + var result = f.Evaluate(baseUser, featureStore, EventFactory.Default); + + var expected = new EvaluationDetail(new JValue("off"), 1, EvaluationReason.Off.Instance); + Assert.Equal(expected, result.Result); + Assert.Equal(0, result.PrerequisiteEvents.Count); + } + + [Fact] + public void FlagReturnsNullIfFlagIsOffAndOffVariationIsUnspecified() + { + var f = new FeatureFlagBuilder("feature") + .On(false) + .FallthroughVariation(0) + .Variations(new List { new JValue("fall"), new JValue("off"), new JValue("on") }) + .Build(); + var result = f.Evaluate(baseUser, featureStore, EventFactory.Default); + + var expected = new EvaluationDetail(null, null, EvaluationReason.Off.Instance); + Assert.Equal(expected, result.Result); + Assert.Equal(0, result.PrerequisiteEvents.Count); + } + + [Fact] + public void FlagReturnsErrorIfFlagIsOffAndOffVariationIsTooHigh() + { + var f = new FeatureFlagBuilder("feature") + .On(false) + .OffVariation(999) + .FallthroughVariation(0) + .Variations(new List { new JValue("fall"), new JValue("off"), new JValue("on") }) + .Build(); + var result = f.Evaluate(baseUser, featureStore, EventFactory.Default); + + var expected = new EvaluationDetail(null, null, + new EvaluationReason.Error(EvaluationErrorKind.MALFORMED_FLAG)); + Assert.Equal(expected, result.Result); + Assert.Equal(0, result.PrerequisiteEvents.Count); + } + + [Fact] + public void FlagReturnsErrorIfFlagIsOffAndOffVariationIsNegative() + { + var f = new FeatureFlagBuilder("feature") + .On(false) + .OffVariation(-1) + .FallthroughVariation(0) + .Variations(new List { new JValue("fall"), new JValue("off"), new JValue("on") }) + .Build(); + var result = f.Evaluate(baseUser, featureStore, EventFactory.Default); + + var expected = new EvaluationDetail(null, null, + new EvaluationReason.Error(EvaluationErrorKind.MALFORMED_FLAG)); + Assert.Equal(expected, result.Result); + Assert.Equal(0, result.PrerequisiteEvents.Count); + } + + [Fact] + public void FlagReturnsFallthroughIfFlagIsOnAndThereAreNoRules() + { + var f = new FeatureFlagBuilder("feature") + .On(true) + .OffVariation(1) + .FallthroughVariation(0) + .Variations(new List { new JValue("fall"), new JValue("off"), new JValue("on") }) + .Build(); + var result = f.Evaluate(baseUser, featureStore, EventFactory.Default); + + var expected = new EvaluationDetail(new JValue("fall"), 0, EvaluationReason.Fallthrough.Instance); + Assert.Equal(expected, result.Result); + Assert.Equal(0, result.PrerequisiteEvents.Count); + } + + [Fact] + public void FlagReturnsErrorIfFallthroughHasTooHighVariation() + { + var f = new FeatureFlagBuilder("feature") + .On(true) + .OffVariation(1) + .FallthroughVariation(999) + .Variations(new List { new JValue("fall"), new JValue("off"), new JValue("on") }) + .Build(); + var result = f.Evaluate(baseUser, featureStore, EventFactory.Default); + + var expected = new EvaluationDetail(null, null, + new EvaluationReason.Error(EvaluationErrorKind.MALFORMED_FLAG)); + Assert.Equal(expected, result.Result); + Assert.Equal(0, result.PrerequisiteEvents.Count); + } + + [Fact] + public void FlagReturnsErrorIfFallthroughHasNegativeVariation() + { + var f = new FeatureFlagBuilder("feature") + .On(true) + .OffVariation(1) + .FallthroughVariation(-1) + .Variations(new List { new JValue("fall"), new JValue("off"), new JValue("on") }) + .Build(); + var result = f.Evaluate(baseUser, featureStore, EventFactory.Default); + + var expected = new EvaluationDetail(null, null, + new EvaluationReason.Error(EvaluationErrorKind.MALFORMED_FLAG)); + Assert.Equal(expected, result.Result); + Assert.Equal(0, result.PrerequisiteEvents.Count); + } + + [Fact] + public void FlagReturnsErrorIfFallthroughHasNeitherVariationNorRollout() + { + var f = new FeatureFlagBuilder("feature") + .On(true) + .OffVariation(1) + .Fallthrough(new VariationOrRollout(null, null)) + .Variations(new List { new JValue("fall"), new JValue("off"), new JValue("on") }) + .Build(); + var result = f.Evaluate(baseUser, featureStore, EventFactory.Default); + + var expected = new EvaluationDetail(null, null, + new EvaluationReason.Error(EvaluationErrorKind.MALFORMED_FLAG)); + Assert.Equal(expected, result.Result); + Assert.Equal(0, result.PrerequisiteEvents.Count); + } + + [Fact] + public void FlagReturnsErrorIfFallthroughHasEmptyRolloutVariationList() + { + var f = new FeatureFlagBuilder("feature") + .On(true) + .OffVariation(1) + .Fallthrough(new VariationOrRollout(null, new Rollout(new List(), null))) + .Variations(new List { new JValue("fall"), new JValue("off"), new JValue("on") }) + .Build(); + var result = f.Evaluate(baseUser, featureStore, EventFactory.Default); + + var expected = new EvaluationDetail(null, null, + new EvaluationReason.Error(EvaluationErrorKind.MALFORMED_FLAG)); + Assert.Equal(expected, result.Result); + Assert.Equal(0, result.PrerequisiteEvents.Count); + } + + [Fact] + public void FlagReturnsOffVariationIfPrerequisiteIsNotFound() + { + var f0 = new FeatureFlagBuilder("feature0") + .On(true) + .Prerequisites(new List { new Prerequisite("feature1", 1) }) + .OffVariation(1) + .FallthroughVariation(0) + .Variations(new List { new JValue("fall"), new JValue("off"), new JValue("on") }) + .Build(); + var result = f0.Evaluate(baseUser, featureStore, EventFactory.Default); + + var expected = new EvaluationDetail(new JValue("off"), 1, + new EvaluationReason.PrerequisiteFailed("feature1")); + Assert.Equal(expected, result.Result); + Assert.Equal(0, result.PrerequisiteEvents.Count); + } + + [Fact] + public void FlagReturnsOffVariationAndEventIfPrerequisiteIsOff() + { + var f0 = new FeatureFlagBuilder("feature0") + .On(true) + .Prerequisites(new List { new Prerequisite("feature1", 1) }) + .OffVariation(1) + .FallthroughVariation(0) + .Variations(new List { new JValue("fall"), new JValue("off"), new JValue("on") }) + .Version(1) + .Build(); + var f1 = new FeatureFlagBuilder("feature1") + .On(false) + .OffVariation(1) + // note that even though it returns the desired variation, it is still off and therefore not a match + .Variations(new List { new JValue("nogo"), new JValue("go") }) + .Version(2) + .Build(); + featureStore.Upsert(VersionedDataKind.Features, f1); + + var result = f0.Evaluate(baseUser, featureStore, EventFactory.Default); + + var expected = new EvaluationDetail(new JValue("off"), 1, + new EvaluationReason.PrerequisiteFailed("feature1")); + Assert.Equal(expected, result.Result); + + Assert.Equal(1, result.PrerequisiteEvents.Count); + FeatureRequestEvent e = result.PrerequisiteEvents[0]; + Assert.Equal(f1.Key, e.Key); + Assert.Equal(new JValue("go"), e.Value); + Assert.Equal(f1.Version, e.Version); + Assert.Equal(f0.Key, e.PrereqOf); + } + + [Fact] + public void FlagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() + { + var f0 = new FeatureFlagBuilder("feature0") + .On(true) + .Prerequisites(new List { new Prerequisite("feature1", 1) }) + .OffVariation(1) + .FallthroughVariation(0) + .Variations(new List { new JValue("fall"), new JValue("off"), new JValue("on") }) + .Version(1) + .Build(); + var f1 = new FeatureFlagBuilder("feature1") + .On(true) + .FallthroughVariation(0) + .Variations(new List { new JValue("nogo"), new JValue("go") }) + .Version(2) + .Build(); + featureStore.Upsert(VersionedDataKind.Features, f1); + + var result = f0.Evaluate(baseUser, featureStore, EventFactory.Default); + + var expected = new EvaluationDetail(new JValue("off"), 1, + new EvaluationReason.PrerequisiteFailed("feature1")); + Assert.Equal(expected, result.Result); + + Assert.Equal(1, result.PrerequisiteEvents.Count); + FeatureRequestEvent e = result.PrerequisiteEvents[0]; + Assert.Equal(f1.Key, e.Key); + Assert.Equal(new JValue("nogo"), e.Value); + Assert.Equal(f1.Version, e.Version); + Assert.Equal(f0.Key, e.PrereqOf); + } + + [Fact] + public void FlagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAreNoRules() + { + var f0 = new FeatureFlagBuilder("feature0") + .On(true) + .Prerequisites(new List { new Prerequisite("feature1", 1) }) + .OffVariation(1) + .FallthroughVariation(0) + .Variations(new List { new JValue("fall"), new JValue("off"), new JValue("on") }) + .Version(1) + .Build(); + var f1 = new FeatureFlagBuilder("feature1") + .On(true) + .FallthroughVariation(1) // this is what makes the prerequisite pass + .Variations(new List { new JValue("nogo"), new JValue("go") }) + .Version(2) + .Build(); + featureStore.Upsert(VersionedDataKind.Features, f1); + + var result = f0.Evaluate(baseUser, featureStore, EventFactory.Default); + + var expected = new EvaluationDetail(new JValue("fall"), 0, EvaluationReason.Fallthrough.Instance); + Assert.Equal(expected, result.Result); + + Assert.Equal(1, result.PrerequisiteEvents.Count); + FeatureRequestEvent e = result.PrerequisiteEvents[0]; + Assert.Equal(f1.Key, e.Key); + Assert.Equal(new JValue("go"), e.Value); + Assert.Equal(f1.Version, e.Version); + Assert.Equal(f0.Key, e.PrereqOf); + } + + [Fact] + public void MultipleLevelsOfPrerequisitesProduceMultipleEvents() + { + var f0 = new FeatureFlagBuilder("feature0") + .On(true) + .Prerequisites(new List { new Prerequisite("feature1", 1) }) + .OffVariation(1) + .FallthroughVariation(0) + .Variations(new List { new JValue("fall"), new JValue("off"), new JValue("on") }) + .Version(1) + .Build(); + var f1 = new FeatureFlagBuilder("feature1") + .On(true) + .Prerequisites(new List { new Prerequisite("feature2", 1) }) + .FallthroughVariation(1) + .Variations(new List { new JValue("nogo"), new JValue("go") }) + .Version(2) + .Build(); + var f2 = new FeatureFlagBuilder("feature2") + .On(true) + .FallthroughVariation(1) + .Variations(new List { new JValue("nogo"), new JValue("go") }) + .Version(3) + .Build(); + featureStore.Upsert(VersionedDataKind.Features, f1); + featureStore.Upsert(VersionedDataKind.Features, f2); + + var result = f0.Evaluate(baseUser, featureStore, EventFactory.Default); + + var expected = new EvaluationDetail(new JValue("fall"), 0, EvaluationReason.Fallthrough.Instance); + Assert.Equal(expected, result.Result); + + Assert.Equal(2, result.PrerequisiteEvents.Count); + + FeatureRequestEvent e0 = result.PrerequisiteEvents[0]; + Assert.Equal(f2.Key, e0.Key); + Assert.Equal(new JValue("go"), e0.Value); + Assert.Equal(f2.Version, e0.Version); + Assert.Equal(f1.Key, e0.PrereqOf); + + FeatureRequestEvent e1 = result.PrerequisiteEvents[1]; + Assert.Equal(f1.Key, e1.Key); + Assert.Equal(new JValue("go"), e1.Value); + Assert.Equal(f1.Version, e1.Version); + Assert.Equal(f0.Key, e1.PrereqOf); + } + + [Fact] + public void FlagMatchesUserFromTargets() + { + var f = new FeatureFlagBuilder("feature") + .On(true) + .Targets(new List { new Target(new List { "whoever", "userkey" }, 2) }) + .FallthroughVariation(0) + .OffVariation(1) + .Variations(new List { new JValue("fall"), new JValue("off"), new JValue("on") }) + .Build(); + var user = User.WithKey("userkey"); + var result = f.Evaluate(user, featureStore, EventFactory.Default); + + var expected = new EvaluationDetail(new JValue("on"), 2, EvaluationReason.TargetMatch.Instance); + Assert.Equal(expected, result.Result); + Assert.Equal(0, result.PrerequisiteEvents.Count); + } + + [Fact] + public void FlagMatchesUserFromRules() + { + var clause0 = new Clause("key", "in", new List { new JValue("wrongkey") }, false); + var clause1 = new Clause("key", "in", new List { new JValue("userkey") }, false); + var rule0 = new Rule("ruleid0", 2, null, new List { clause0 }); + var rule1 = new Rule("ruleid1", 2, null, new List { clause1 }); + var f = FeatureFlagWithRules(rule0, rule1); + + var user = User.WithKey("userkey"); + var result = f.Evaluate(user, featureStore, EventFactory.Default); + + var expected = new EvaluationDetail(new JValue("on"), 2, + new EvaluationReason.RuleMatch(1, "ruleid1")); + Assert.Equal(expected, result.Result); + Assert.Equal(0, result.PrerequisiteEvents.Count); + } + + [Fact] + public void RuleWithTooHighVariationReturnsMalformedFlagError() + { + var clause = new Clause("key", "in", new List { new JValue("userkey") }, false); + var rule = new Rule("ruleid", 999, null, new List { clause }); + var f = FeatureFlagWithRules(rule); + + var user = User.WithKey("userkey"); + var result = f.Evaluate(user, featureStore, EventFactory.Default); + + var expected = new EvaluationDetail(null, null, + new EvaluationReason.Error(EvaluationErrorKind.MALFORMED_FLAG)); + Assert.Equal(expected, result.Result); + Assert.Equal(0, result.PrerequisiteEvents.Count); + } + + [Fact] + public void RuleWithNegativeVariationReturnsMalformedFlagError() + { + var clause = new Clause("key", "in", new List { new JValue("userkey") }, false); + var rule = new Rule("ruleid", -1, null, new List { clause }); + var f = FeatureFlagWithRules(rule); + + var user = User.WithKey("userkey"); + var result = f.Evaluate(user, featureStore, EventFactory.Default); + + var expected = new EvaluationDetail(null, null, + new EvaluationReason.Error(EvaluationErrorKind.MALFORMED_FLAG)); + Assert.Equal(expected, result.Result); + Assert.Equal(0, result.PrerequisiteEvents.Count); + } + + [Fact] + public void RuleWithNoVariationOrRolloutReturnsMalformedFlagError() + { + var clause = new Clause("key", "in", new List { new JValue("userkey") }, false); + var rule = new Rule("ruleid", null, null, new List { clause }); + var f = FeatureFlagWithRules(rule); + + var user = User.WithKey("userkey"); + var result = f.Evaluate(user, featureStore, EventFactory.Default); + + var expected = new EvaluationDetail(null, null, + new EvaluationReason.Error(EvaluationErrorKind.MALFORMED_FLAG)); + Assert.Equal(expected, result.Result); + Assert.Equal(0, result.PrerequisiteEvents.Count); + } + + [Fact] + public void RuleWithRolloutWithEmptyVariationsListReturnsMalformedFlagError() + { + var clause = new Clause("key", "in", new List { new JValue("userkey") }, false); + var rule = new Rule("ruleid", null, + new Rollout(new List(), null), + new List { clause }); + var f = FeatureFlagWithRules(rule); + + var user = User.WithKey("userkey"); + var result = f.Evaluate(user, featureStore, EventFactory.Default); + + var expected = new EvaluationDetail(null, null, + new EvaluationReason.Error(EvaluationErrorKind.MALFORMED_FLAG)); + Assert.Equal(expected, result.Result); + Assert.Equal(0, result.PrerequisiteEvents.Count); + } + + [Fact] + public void ClauseCanMatchBuiltInAttribute() + { + var clause = new Clause("name", "in", new List { new JValue("Bob") }, false); + var f = BooleanFlagWithClauses(clause); + var user = User.WithKey("key").AndName("Bob"); + + Assert.Equal(new JValue(true), f.Evaluate(user, featureStore, EventFactory.Default).Result.Value); + } + + [Fact] + public void ClauseCanMatchCustomAttribute() + { + var clause = new Clause("legs", "in", new List { new JValue(4) }, false); + var f = BooleanFlagWithClauses(clause); + var user = User.WithKey("key").AndCustomAttribute("legs", 4); + + Assert.Equal(new JValue(true), f.Evaluate(user, featureStore, EventFactory.Default).Result.Value); + } + + [Fact] + public void ClauseReturnsFalseForMissingAttribute() + { + var clause = new Clause("legs", "in", new List { new JValue(4) }, false); + var f = BooleanFlagWithClauses(clause); + var user = User.WithKey("key").AndName("bob"); + + Assert.Equal(new JValue(false), f.Evaluate(user, featureStore, EventFactory.Default).Result.Value); + } + + [Fact] + public void ClauseCanBeNegated() + { + var clause = new Clause("name", "in", new List { new JValue("Bob") }, true); + var f = BooleanFlagWithClauses(clause); + var user = User.WithKey("key").AndName("Bob"); + + Assert.Equal(new JValue(false), f.Evaluate(user, featureStore, EventFactory.Default).Result.Value); + } + + [Fact] + public void ClauseWithUnknownOperatorDoesNotMatch() + { + var clause = new Clause("name", "invalidOp", new List { new JValue("Bob") }, false); + var f = BooleanFlagWithClauses(clause); + var user = User.WithKey("key").AndName("Bob"); + + Assert.Equal(new JValue(false), f.Evaluate(user, featureStore, EventFactory.Default).Result.Value); + } + + [Fact] + public void SegmentMatchClauseRetrievesSegmentFromStore() + { + var segment = new Segment("segkey", 1, new List { "foo" }, new List(), "", + new List(), false); + featureStore.Upsert(VersionedDataKind.Segments, segment); + + var f = SegmentMatchBooleanFlag("segkey"); + var user = User.WithKey("foo"); + + Assert.Equal(new JValue(true), f.Evaluate(user, featureStore, EventFactory.Default).Result.Value); + } + + [Fact] + public void SegmentMatchClauseFallsThroughIfSegmentNotFound() + { + var f = SegmentMatchBooleanFlag("segkey"); + var user = User.WithKey("foo"); + + Assert.Equal(new JValue(false), f.Evaluate(user, featureStore, EventFactory.Default).Result.Value); + } + + private FeatureFlag FeatureFlagWithRules(params Rule[] rules) + { + return new FeatureFlagBuilder("feature") + .On(true) + .Rules(new List(rules)) + .FallthroughVariation(0) + .Variations(new List { new JValue("fall"), new JValue("off"), new JValue("on") }) + .Build(); + } + + private FeatureFlag BooleanFlagWithClauses(params Clause[] clauses) + { + var rule = new Rule("id", 1, null, new List(clauses)); + return new FeatureFlagBuilder("feature") + .On(true) + .Rules(new List { rule }) + .FallthroughVariation(0) + .Variations(new List { new JValue(false), new JValue(true) }) + .Build(); + } + + private FeatureFlag SegmentMatchBooleanFlag(string segmentKey) + { + var clause = new Clause("", "segmentMatch", new List { new JValue(segmentKey) }, false); + return BooleanFlagWithClauses(clause); + } + } +} diff --git a/test/LaunchDarkly.Tests/FeatureFlagsStateTest.cs b/test/LaunchDarkly.Tests/FeatureFlagsStateTest.cs index 633bcc4a..b8254077 100644 --- a/test/LaunchDarkly.Tests/FeatureFlagsStateTest.cs +++ b/test/LaunchDarkly.Tests/FeatureFlagsStateTest.cs @@ -15,7 +15,7 @@ public void CanGetFlagValue() { var state = new FeatureFlagsState(true); var flag = new FeatureFlagBuilder("key").Build(); - state.AddFlag(flag, new JValue("value"), 1); + state.AddFlag(flag, new JValue("value"), 1, null); Assert.Equal(new JValue("value"), state.GetFlagValue("key")); } @@ -28,14 +28,42 @@ public void UnknownFlagReturnsNullValue() Assert.Null(state.GetFlagValue("key")); } + [Fact] + public void CanGetFlagReason() + { + var state = new FeatureFlagsState(true); + var flag = new FeatureFlagBuilder("key").Build(); + state.AddFlag(flag, new JValue("value"), 1, EvaluationReason.Fallthrough.Instance); + + Assert.Equal(EvaluationReason.Fallthrough.Instance, state.GetFlagReason("key")); + } + + [Fact] + public void UnknownFlagReturnsNullReason() + { + var state = new FeatureFlagsState(true); + + Assert.Null(state.GetFlagReason("key")); + } + + [Fact] + public void ReasonIsNullIfReasonsWereNotRecorded() + { + var state = new FeatureFlagsState(true); + var flag = new FeatureFlagBuilder("key").Build(); + state.AddFlag(flag, new JValue("value"), 1, null); + + Assert.Null(state.GetFlagReason("key")); + } + [Fact] public void CanConvertToValuesMap() { var state = new FeatureFlagsState(true); var flag1 = new FeatureFlagBuilder("key1").Build(); var flag2 = new FeatureFlagBuilder("key2").Build(); - state.AddFlag(flag1, new JValue("value1"), 0); - state.AddFlag(flag2, new JValue("value2"), 1); + state.AddFlag(flag1, new JValue("value1"), 0, null); + state.AddFlag(flag2, new JValue("value2"), 1, null); var expected = new Dictionary { @@ -52,15 +80,15 @@ public void CanSerializeToJson() var flag1 = new FeatureFlagBuilder("key1").Version(100).Build(); var flag2 = new FeatureFlagBuilder("key2").Version(200) .TrackEvents(true).DebugEventsUntilDate(1000).Build(); - state.AddFlag(flag1, new JValue("value1"), 0); - state.AddFlag(flag2, new JValue("value2"), 1); + state.AddFlag(flag1, new JValue("value1"), 0, null); + state.AddFlag(flag2, new JValue("value2"), 1, EvaluationReason.Fallthrough.Instance); var expectedString = @"{""key1"":""value1"",""key2"":""value2"", ""$flagsState"":{ ""key1"":{ ""variation"":0,""version"":100,""trackEvents"":false },""key2"":{ - ""variation"":1,""version"":200,""trackEvents"":true,""debugEventsUntilDate"":1000 + ""variation"":1,""version"":200,""reason"":{""kind"":""FALLTHROUGH""},""trackEvents"":true,""debugEventsUntilDate"":1000 } }, ""$valid"":true @@ -78,8 +106,8 @@ public void CanDeserializeFromJson() var flag1 = new FeatureFlagBuilder("key1").Version(100).Build(); var flag2 = new FeatureFlagBuilder("key2").Version(200) .TrackEvents(true).DebugEventsUntilDate(1000).Build(); - state.AddFlag(flag1, new JValue("value1"), 0); - state.AddFlag(flag2, new JValue("value2"), 1); + state.AddFlag(flag1, new JValue("value1"), 0, null); + state.AddFlag(flag2, new JValue("value2"), 1, EvaluationReason.Fallthrough.Instance); var jsonString = JsonConvert.SerializeObject(state); var state1 = JsonConvert.DeserializeObject(jsonString); diff --git a/test/LaunchDarkly.Tests/JsonSerializationTest.cs b/test/LaunchDarkly.Tests/JsonSerializationTest.cs index 390a9d26..7ffcb13c 100644 --- a/test/LaunchDarkly.Tests/JsonSerializationTest.cs +++ b/test/LaunchDarkly.Tests/JsonSerializationTest.cs @@ -56,7 +56,7 @@ private FeatureFlag BuildFlag() var clause = new Clause("name", "in", new List { new JValue("x") }, true); var wv = new WeightedVariation(0, 50); var rollout = new Rollout(new List { wv }, "key"); - var rule = new Rule(0, rollout, new List { clause }); + var rule = new Rule("ruleid", 0, rollout, new List { clause }); var target = new Target(new List { "userkey" }, 0); return new FeatureFlagBuilder("flagkey") .DebugEventsUntilDate(100000) @@ -93,6 +93,7 @@ private JToken BuildFlagJson() ""prerequisites"": [ { ""key"": ""prereq"", ""variation"": 1 } ], ""rules"": [ { + ""id"": ""ruleid"", ""variation"": 0, ""rollout"": { ""variations"": [ { ""variation"": 0, ""weight"": 50 } ], diff --git a/test/LaunchDarkly.Tests/LaunchDarkly.Tests.csproj b/test/LaunchDarkly.Tests/LaunchDarkly.Tests.csproj index f88acbd2..00f0dbdd 100644 --- a/test/LaunchDarkly.Tests/LaunchDarkly.Tests.csproj +++ b/test/LaunchDarkly.Tests/LaunchDarkly.Tests.csproj @@ -20,7 +20,7 @@ - + diff --git a/test/LaunchDarkly.Tests/LdClientEvaluationTest.cs b/test/LaunchDarkly.Tests/LdClientEvaluationTest.cs index faab8998..d18681cc 100644 --- a/test/LaunchDarkly.Tests/LdClientEvaluationTest.cs +++ b/test/LaunchDarkly.Tests/LdClientEvaluationTest.cs @@ -6,6 +6,9 @@ namespace LaunchDarkly.Tests { + // Note, exhaustive coverage of all the code paths for evaluation is in FeatureFlagTest. + // LdClientEvaluationTest verifies that the LdClient evaluation methods do what they're + // supposed to do, regardless of exactly what value we get. public class LdClientEvaluationTest { private static readonly User user = User.WithKey("userkey"); @@ -45,6 +48,16 @@ public void BoolVariationReturnsDefaultValueForWrongType() Assert.Equal(false, client.BoolVariation("key", user, false)); } + [Fact] + public void BoolVariationDetailReturnsValueAndReason() + { + featureStore.Upsert(VersionedDataKind.Features, + new FeatureFlagBuilder("key").OffWithValue(new JValue(true)).Build()); + + var expected = new EvaluationDetail(true, 0, EvaluationReason.Off.Instance); + Assert.Equal(expected, client.BoolVariationDetail("key", user, false)); + } + [Fact] public void IntVariationReturnsFlagValue() { @@ -78,6 +91,16 @@ public void IntVariationReturnsDefaultValueForWrongType() Assert.Equal(1, client.IntVariation("key", user, 1)); } + [Fact] + public void IntVariationDetailReturnsValueAndReason() + { + featureStore.Upsert(VersionedDataKind.Features, + new FeatureFlagBuilder("key").OffWithValue(new JValue(2)).Build()); + + var expected = new EvaluationDetail(2, 0, EvaluationReason.Off.Instance); + Assert.Equal(expected, client.IntVariationDetail("key", user, 1)); + } + [Fact] public void FloatVariationReturnsFlagValue() { @@ -111,6 +134,16 @@ public void FloatVariationReturnsDefaultValueForWrongType() Assert.Equal(1.0f, client.FloatVariation("key", user, 1.0f)); } + [Fact] + public void FloatVariationDetailReturnsValueAndReason() + { + featureStore.Upsert(VersionedDataKind.Features, + new FeatureFlagBuilder("key").OffWithValue(new JValue(2.5f)).Build()); + + var expected = new EvaluationDetail(2.5f, 0, EvaluationReason.Off.Instance); + Assert.Equal(expected, client.FloatVariationDetail("key", user, 1.0f)); + } + [Fact] public void StringVariationReturnsFlagValue() { @@ -135,6 +168,16 @@ public void StringVariationReturnsDefaultValueForWrongType() Assert.Equal("a", client.StringVariation("key", user, "a")); } + [Fact] + public void StringVariationDetailReturnsValueAndReason() + { + featureStore.Upsert(VersionedDataKind.Features, + new FeatureFlagBuilder("key").OffWithValue(new JValue("b")).Build()); + + var expected = new EvaluationDetail("b", 0, EvaluationReason.Off.Instance); + Assert.Equal(expected, client.StringVariationDetail("key", user, "a")); + } + [Fact] public void JsonVariationReturnsFlagValue() { @@ -152,7 +195,70 @@ public void JsonVariationReturnsDefaultValueForUnknownFlag() var defaultVal = new JValue(42); Assert.Equal(defaultVal, client.JsonVariation("key", user, defaultVal)); } + + [Fact] + public void JsonVariationDetailReturnsValueAndReason() + { + var data = new JObject(); + data.Add("thing", new JValue("stuff")); + featureStore.Upsert(VersionedDataKind.Features, + new FeatureFlagBuilder("key").OffWithValue(data).Build()); + + var expected = new EvaluationDetail(data, 0, EvaluationReason.Off.Instance); + Assert.Equal(expected, client.JsonVariationDetail("key", user, new JValue(42))); + } + + [Fact] + public void VariationDetailReturnsDefaultForUnknownFlag() + { + var expected = new EvaluationDetail("default", null, + new EvaluationReason.Error(EvaluationErrorKind.FLAG_NOT_FOUND)); + Assert.Equal(expected, client.StringVariationDetail("key", null, "default")); + } + [Fact] + public void VariationDetailReturnsDefaultForNullUser() + { + featureStore.Upsert(VersionedDataKind.Features, + new FeatureFlagBuilder("key").OffWithValue(new JValue("b")).Build()); + + var expected = new EvaluationDetail("default", null, + new EvaluationReason.Error(EvaluationErrorKind.USER_NOT_SPECIFIED)); + Assert.Equal(expected, client.StringVariationDetail("key", null, "default")); + } + + [Fact] + public void VariationDetailReturnsDefaultForUserWithNullKey() + { + featureStore.Upsert(VersionedDataKind.Features, + new FeatureFlagBuilder("key").OffWithValue(new JValue("b")).Build()); + + var expected = new EvaluationDetail("default", null, + new EvaluationReason.Error(EvaluationErrorKind.USER_NOT_SPECIFIED)); + Assert.Equal(expected, client.StringVariationDetail("key", User.WithKey(null), "default")); + } + + [Fact] + public void VariationDetailReturnsDefaultForFlagThatEvaluatesToNull() + { + featureStore.Upsert(VersionedDataKind.Features, + new FeatureFlagBuilder("key").On(false).OffVariation(null).Build()); + + var expected = new EvaluationDetail("default", null, EvaluationReason.Off.Instance); + Assert.Equal(expected, client.StringVariationDetail("key", user, "default")); + } + + [Fact] + public void VariationDetailReturnsDefaultForWrongType() + { + featureStore.Upsert(VersionedDataKind.Features, + new FeatureFlagBuilder("key").OffWithValue(new JValue("wrong")).Build()); + + var expected = new EvaluationDetail(1, null, + new EvaluationReason.Error(EvaluationErrorKind.WRONG_TYPE)); + Assert.Equal(expected, client.IntVariationDetail("key", user, 1)); + } + [Fact] public void CanMatchUserBySegment() { @@ -241,6 +347,38 @@ public void AllFlagsStateReturnsState() TestUtils.AssertJsonEqual(expectedValue, actualValue); } + [Fact] + public void AllFlagsStateReturnsStateWithReasons() + { + var flag1 = new FeatureFlagBuilder("key1").Version(100) + .OffVariation(0).Variations(new List { new JValue("value1") }) + .Build(); + var flag2 = new FeatureFlagBuilder("key2").Version(200) + .OffVariation(1).Variations(new List { new JValue("x"), new JValue("value2") }) + .TrackEvents(true).DebugEventsUntilDate(1000) + .Build(); + featureStore.Upsert(VersionedDataKind.Features, flag1); + featureStore.Upsert(VersionedDataKind.Features, flag2); + + var state = client.AllFlagsState(user, FlagsStateOption.WithReasons); + Assert.True(state.Valid); + + var expectedString = @"{""key1"":""value1"",""key2"":""value2"", + ""$flagsState"":{ + ""key1"":{ + ""variation"":0,""version"":100,""reason"":{""kind"":""OFF""},""trackEvents"":false + },""key2"":{ + ""variation"":1,""version"":200,""reason"":{""kind"":""OFF""},""trackEvents"":true,""debugEventsUntilDate"":1000 + } + }, + ""$valid"":true + }"; + var expectedValue = JsonConvert.DeserializeObject(expectedString); + var actualString = JsonConvert.SerializeObject(state); + var actualValue = JsonConvert.DeserializeObject(actualString); + TestUtils.AssertJsonEqual(expectedValue, actualValue); + } + [Fact] public void AllFlagsStateCanFilterForOnlyClientSideFlags() {