Skip to content

Commit

Permalink
feat: add feature flag insights support
Browse files Browse the repository at this point in the history
  • Loading branch information
deleteLater authored Mar 14, 2023
1 parent a9b5060 commit d8fd1fb
Show file tree
Hide file tree
Showing 39 changed files with 1,883 additions and 64 deletions.
2 changes: 1 addition & 1 deletion examples/ConsoleApp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
Console.WriteLine();
}

// Shuts down the client
// Shuts down the client to ensure all pending events are sent.
await client.CloseAsync();

Environment.Exit(1);
76 changes: 76 additions & 0 deletions src/FeatBit.ServerSdk/Concurrent/AtomicBoolean.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using System.Threading;

// From: https://github.com/akkadotnet/akka.net/blob/dev/src/core/Akka/Util/AtomicBoolean.cs
namespace FeatBit.Sdk.Server.Concurrent
{
/// <summary>
/// Implementation of the java.concurrent.util.AtomicBoolean type.
///
/// Uses <see cref="Interlocked.MemoryBarrier"/> internally to enforce ordering of writes
/// without any explicit locking. .NET's strong memory on write guarantees might already enforce
/// this ordering, but the addition of the MemoryBarrier guarantees it.
/// </summary>
public class AtomicBoolean
{
private const int FalseValue = 0;
private const int TrueValue = 1;

private int _value;

/// <summary>
/// Sets the initial value of this <see cref="AtomicBoolean"/> to <paramref name="initialValue"/>.
/// </summary>
/// <param name="initialValue">TBD</param>
public AtomicBoolean(bool initialValue = false)
{
_value = initialValue ? TrueValue : FalseValue;
}

/// <summary>
/// The current value of this <see cref="AtomicBoolean"/>
/// </summary>
public bool Value
{
get
{
Interlocked.MemoryBarrier();
return _value == TrueValue;
}
set { Interlocked.Exchange(ref _value, value ? TrueValue : FalseValue); }
}

/// <summary>
/// If <see cref="Value"/> equals <paramref name="expected"/>, then set the Value to
/// <paramref name="newValue"/>.
/// </summary>
/// <param name="expected">TBD</param>
/// <param name="newValue">TBD</param>
/// <returns><c>true</c> if <paramref name="newValue"/> was set</returns>
public bool CompareAndSet(bool expected, bool newValue)
{
var expectedInt = expected ? TrueValue : FalseValue;
var newInt = newValue ? TrueValue : FalseValue;
return Interlocked.CompareExchange(ref _value, newInt, expectedInt) == expectedInt;
}

/// <summary>
/// Atomically sets the <see cref="Value"/> to <paramref name="newValue"/> and returns the old <see cref="Value"/>.
/// </summary>
/// <param name="newValue">The new value</param>
/// <returns>The old value</returns>
public bool GetAndSet(bool newValue)
{
return Interlocked.Exchange(ref _value, newValue ? TrueValue : FalseValue) == TrueValue;
}

/// <summary>
/// Performs an implicit conversion from <see cref="AtomicBoolean"/> to <see cref="System.Boolean"/>.
/// </summary>
/// <param name="atomicBoolean">The boolean to convert</param>
/// <returns>The result of the conversion.</returns>
public static implicit operator bool(AtomicBoolean atomicBoolean)
{
return atomicBoolean.Value;
}
}
}
2 changes: 1 addition & 1 deletion src/FeatBit.ServerSdk/Evaluation/EvalResult.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
namespace FeatBit.Sdk.Server.Evaluation
{
public class EvalResult
internal class EvalResult
{
public ReasonKind Kind { get; set; }

Expand Down
108 changes: 95 additions & 13 deletions src/FeatBit.ServerSdk/Evaluation/Evaluator.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Linq;
using System.Runtime.CompilerServices;
using FeatBit.Sdk.Server.Events;
using FeatBit.Sdk.Server.Model;
using FeatBit.Sdk.Server.Store;

Expand All @@ -13,42 +15,45 @@ public Evaluator(IMemoryStore store)
_store = store;
}

public EvalResult Evaluate(EvaluationContext context)
public (EvalResult evalResult, EvalEvent evalEvent) Evaluate(EvaluationContext context)
{
var storeKey = StoreKeys.ForFeatureFlag(context.FlagKey);

var flag = _store.Get<FeatureFlag>(storeKey);
if (flag == null)
{
return EvalResult.FlagNotFound;
return FlagNotFound();
}

return Evaluate(flag, context.FbUser);

(EvalResult EvalResult, EvalEvent evalEvent) FlagNotFound() => (EvalResult.FlagNotFound, null);
}

public EvalResult Evaluate(FeatureFlag flag, FbUser user)
public (EvalResult evalResult, EvalEvent evalEvent) Evaluate(FeatureFlag flag, FbUser user)
{
var flagKey = flag.Key;

// if flag is disabled
if (!flag.IsEnabled)
{
var disabledVariation = flag.GetVariation(flag.DisabledVariationId);
if (disabledVariation == null)
{
return EvalResult.MalformedFlag;
return MalformedFlag();
}

return EvalResult.FlagOff(disabledVariation.Value);
return FlagOff(disabledVariation);
}

// if user is targeted
var targetUser = flag.TargetUsers.FirstOrDefault(x => x.KeyIds.Contains(user.Key));
if (targetUser != null)
{
var targetedVariation = flag.GetVariation(targetUser.VariationId);
return EvalResult.Targeted(targetedVariation.Value);
return Targeted(targetedVariation, flag.ExptIncludeAllTargets);
}

var flagKey = flag.Key;
string dispatchKey;

// if user is rule matched
Expand All @@ -64,11 +69,10 @@ public EvalResult Evaluate(FeatureFlag flag, FbUser user)
var rolloutVariation = rule.Variations.FirstOrDefault(x => x.IsInRollout(dispatchKey));
if (rolloutVariation == null)
{
return EvalResult.MalformedFlag;
return MalformedFlag();
}

var ruleMatchedVariation = flag.GetVariation(rolloutVariation.Id);
return EvalResult.RuleMatched(ruleMatchedVariation.Value, rule.Name);
return RuleMatched(rule, rolloutVariation);
}
}

Expand All @@ -82,11 +86,89 @@ public EvalResult Evaluate(FeatureFlag flag, FbUser user)
flag.Fallthrough.Variations.FirstOrDefault(x => x.IsInRollout(dispatchKey));
if (defaultVariation == null)
{
return EvalResult.MalformedFlag;
return MalformedFlag();
}

return Fallthrough();

(EvalResult EvalResult, EvalEvent evalEvent) MalformedFlag() => (EvalResult.MalformedFlag, null);

(EvalResult EvalResult, EvalEvent evalEvent) FlagOff(Variation variation) =>
(EvalResult.FlagOff(variation.Value), new EvalEvent(user, flagKey, variation, false));

(EvalResult EvalResult, EvalEvent evalEvent) Targeted(Variation variation, bool exptIncludeAllTargets) =>
(EvalResult.Targeted(variation.Value), new EvalEvent(user, flagKey, variation, exptIncludeAllTargets));

(EvalResult EvalResult, EvalEvent evalEvent) RuleMatched(TargetRule rule, RolloutVariation rolloutVariation)
{
var variation = flag.GetVariation(rolloutVariation.Id);

var evalResult = EvalResult.RuleMatched(variation.Value, rule.Name);

var sendToExperiment = IsSendToExperiment(
flag.ExptIncludeAllTargets,
rule.IncludedInExpt,
dispatchKey,
rolloutVariation
);
var evalEvent = new EvalEvent(user, flagKey, variation, sendToExperiment);

return (evalResult, evalEvent);
}

(EvalResult EvalResult, EvalEvent evalEvent) Fallthrough()
{
var variation = flag.GetVariation(defaultVariation.Id);

var evalResult = EvalResult.Fallthrough(variation.Value);

var sendToExperiment = IsSendToExperiment(
flag.ExptIncludeAllTargets,
flag.Fallthrough.IncludedInExpt,
dispatchKey,
defaultVariation
);
var evalEvent = new EvalEvent(user, flagKey, variation, sendToExperiment);

return (evalResult, evalEvent);
}
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool IsSendToExperiment(
bool exptIncludeAllTargets,
bool thisRuleIncludeInExpt,
string dispatchKey,
RolloutVariation rolloutVariation)
{
if (exptIncludeAllTargets)
{
return true;
}

if (!thisRuleIncludeInExpt)
{
return false;
}

// create a new key to calculate the experiment dispatch percentage
const string exptDispatchKeyPrefix = "expt";
var sendToExptKey = $"{exptDispatchKeyPrefix}{dispatchKey}";

var exptRollout = rolloutVariation.ExptRollout;
var dispatchRollout = rolloutVariation.DispatchRollout();
if (exptRollout == 0.0 || dispatchRollout == 0.0)
{
return false;
}

var upperBound = exptRollout / dispatchRollout;
if (upperBound > 1.0)
{
upperBound = 1.0;
}

var defaultRuleVariation = flag.GetVariation(defaultVariation.Id);
return EvalResult.Fallthrough(defaultRuleVariation.Value);
return DispatchAlgorithm.IsInRollout(sendToExptKey, new[] { 0.0, upperBound });
}
}
}
5 changes: 3 additions & 2 deletions src/FeatBit.ServerSdk/Evaluation/IEvaluator.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
using FeatBit.Sdk.Server.Events;
using FeatBit.Sdk.Server.Model;

namespace FeatBit.Sdk.Server.Evaluation
{
internal interface IEvaluator
{
EvalResult Evaluate(EvaluationContext context);
(EvalResult evalResult, EvalEvent evalEvent) Evaluate(EvaluationContext context);

EvalResult Evaluate(FeatureFlag flag, FbUser user);
(EvalResult evalResult, EvalEvent evalEvent) Evaluate(FeatureFlag flag, FbUser user);
}
}
35 changes: 35 additions & 0 deletions src/FeatBit.ServerSdk/Events/DefaultEventBuffer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System.Collections.Generic;

namespace FeatBit.Sdk.Server.Events
{
internal sealed class DefaultEventBuffer : IEventBuffer
{
private readonly int _capacity;
private readonly List<IEvent> _events;

public DefaultEventBuffer(int capacity)
{
_capacity = capacity;
_events = new List<IEvent>();
}

public bool AddEvent(IEvent @event)
{
if (_events.Count >= _capacity)
{
return false;
}

_events.Add(@event);
return true;
}

public int Count => _events.Count;

public bool IsEmpty => Count == 0;

public void Clear() => _events.Clear();

public IEvent[] EventsSnapshot => _events.ToArray();
}
}
Loading

0 comments on commit d8fd1fb

Please sign in to comment.