diff --git a/README.md b/README.md index acb31e8f..013f620e 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,19 @@ await Api.Instance.SetProviderAsync(myClient.GetMetadata().Name, provider); myClient.AddHandler(ProviderEventTypes.ProviderReady, callback); ``` +### Tracking + +The tracking API allows you to use OpenFeature abstractions and objects to associate user actions with feature flag evaluations. +This is essential for robust experimentation powered by feature flags. +For example, a flag enhancing the appearance of a UI component might drive user engagement to a new feature; to test this hypothesis, telemetry collected by a hook(#hooks) or provider(#providers) can be associated with telemetry reported in the client's `track` function. + +```csharp +var client = Api.Instance.GetClient(); +client.Track("visited-promo-page", trackingEventDetails: new TrackingEventDetailsBuilder().SetValue(99.77).Set("currency", "USD").Build()); +``` + +Note that some providers may not support tracking; check the documentation for your provider for more information. + ### Shutdown The OpenFeature API provides a close function to perform a cleanup of all registered providers. This should only be called when your application is in the process of shutting down. @@ -320,7 +333,7 @@ builder.Services.AddOpenFeature(featureBuilder => { featureBuilder .AddHostedFeatureLifecycle() // From Hosting package .AddContext((contextBuilder, serviceProvider) => { /* Custom context configuration */ }) - .AddInMemoryProvider(); + .AddInMemoryProvider(); }); ``` **Domain-Scoped Provider Configuration:** diff --git a/src/OpenFeature/FeatureProvider.cs b/src/OpenFeature/FeatureProvider.cs index c4ce8783..74af90b2 100644 --- a/src/OpenFeature/FeatureProvider.cs +++ b/src/OpenFeature/FeatureProvider.cs @@ -7,6 +7,7 @@ using OpenFeature.Model; [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // required to allow NSubstitute mocking of internal methods + namespace OpenFeature { /// @@ -140,5 +141,16 @@ public virtual Task ShutdownAsync(CancellationToken cancellationToken = default) /// /// The event channel of the provider public virtual Channel GetEventChannel() => this.EventChannel; + + /// + /// Use this method to track user interactions and the application state. The implementation of this method is optional. + /// + /// The name associated with this tracking event + /// The evaluation context used in the evaluation of the flag (optional) + /// Data pertinent to the tracking event (Optional) + public virtual void Track(string trackingEventName, EvaluationContext? evaluationContext = default, TrackingEventDetails? trackingEventDetails = default) + { + // Intentionally left blank. + } } } diff --git a/src/OpenFeature/Model/TrackingEventDetails.cs b/src/OpenFeature/Model/TrackingEventDetails.cs new file mode 100644 index 00000000..f247daad --- /dev/null +++ b/src/OpenFeature/Model/TrackingEventDetails.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace OpenFeature.Model; + +/// +/// The `tracking event details` structure defines optional data pertinent to a particular `tracking event`. +/// +/// +public sealed class TrackingEventDetails +{ + /// + /// The index for the "targeting key" property when the EvaluationContext is serialized or expressed as a dictionary. + /// + internal const string TargetingKeyIndex = "targetingKey"; + + /// + ///A predefined value field for the tracking details. + /// + public readonly double? Value; + + private readonly Structure _structure; + + /// + /// Internal constructor used by the builder. + /// + /// + /// + internal TrackingEventDetails(Structure content, double? value) + { + this.Value = value; + this._structure = content; + } + + + /// + /// Private constructor for making an empty . + /// + private TrackingEventDetails() + { + this._structure = Structure.Empty; + this.Value = null; + } + + /// + /// Empty tracking event details. + /// + public static TrackingEventDetails Empty { get; } = new(); + + + /// + /// Gets the Value at the specified key + /// + /// The key of the value to be retrieved + /// The associated with the key + /// + /// Thrown when the context does not contain the specified key + /// + /// + /// Thrown when the key is + /// + public Value GetValue(string key) => this._structure.GetValue(key); + + /// + /// Bool indicating if the specified key exists in the evaluation context + /// + /// The key of the value to be checked + /// indicating the presence of the key + /// + /// Thrown when the key is + /// + public bool ContainsKey(string key) => this._structure.ContainsKey(key); + + /// + /// Gets the value associated with the specified key + /// + /// The or if the key was not present + /// The key of the value to be retrieved + /// indicating the presence of the key + /// + /// Thrown when the key is + /// + public bool TryGetValue(string key, out Value? value) => this._structure.TryGetValue(key, out value); + + /// + /// Gets all values as a Dictionary + /// + /// New representation of this Structure + public IImmutableDictionary AsDictionary() + { + return this._structure.AsDictionary(); + } + + /// + /// Return a count of all values + /// + public int Count => this._structure.Count; + + /// + /// Returns the targeting key for the context. + /// + public string? TargetingKey + { + get + { + this._structure.TryGetValue(TargetingKeyIndex, out Value? targetingKey); + return targetingKey?.AsString; + } + } + + /// + /// Return an enumerator for all values + /// + /// An enumerator for all values + public IEnumerator> GetEnumerator() + { + return this._structure.GetEnumerator(); + } + + /// + /// Get a builder which can build an . + /// + /// The builder + public static TrackingEventDetailsBuilder Builder() + { + return new TrackingEventDetailsBuilder(); + } +} diff --git a/src/OpenFeature/Model/TrackingEventDetailsBuilder.cs b/src/OpenFeature/Model/TrackingEventDetailsBuilder.cs new file mode 100644 index 00000000..4f02efe1 --- /dev/null +++ b/src/OpenFeature/Model/TrackingEventDetailsBuilder.cs @@ -0,0 +1,168 @@ +using System; + +namespace OpenFeature.Model +{ + /// + /// A builder which allows the specification of attributes for an . + /// + /// A object is intended for use by a single thread and should not be used + /// from multiple threads. Once an has been created it is immutable and safe for use + /// from multiple threads. + /// + /// + public sealed class TrackingEventDetailsBuilder + { + private readonly StructureBuilder _attributes = Structure.Builder(); + private double? _value; + + /// + /// Internal to only allow direct creation by . + /// + internal TrackingEventDetailsBuilder() { } + + /// + /// Set the predefined value field for the tracking details. + /// + /// + /// + public TrackingEventDetailsBuilder SetValue(double? value) + { + this._value = value; + return this; + } + + /// + /// Set the targeting key for the tracking details. + /// + /// The targeting key + /// This builder + public TrackingEventDetailsBuilder SetTargetingKey(string targetingKey) + { + this._attributes.Set(TrackingEventDetails.TargetingKeyIndex, targetingKey); + return this; + } + + /// + /// Set the key to the given . + /// + /// The key for the value + /// The value to set + /// This builder + public TrackingEventDetailsBuilder Set(string key, Value value) + { + this._attributes.Set(key, value); + return this; + } + + /// + /// Set the key to the given string. + /// + /// The key for the value + /// The value to set + /// This builder + public TrackingEventDetailsBuilder Set(string key, string value) + { + this._attributes.Set(key, value); + return this; + } + + /// + /// Set the key to the given int. + /// + /// The key for the value + /// The value to set + /// This builder + public TrackingEventDetailsBuilder Set(string key, int value) + { + this._attributes.Set(key, value); + return this; + } + + /// + /// Set the key to the given double. + /// + /// The key for the value + /// The value to set + /// This builder + public TrackingEventDetailsBuilder Set(string key, double value) + { + this._attributes.Set(key, value); + return this; + } + + /// + /// Set the key to the given long. + /// + /// The key for the value + /// The value to set + /// This builder + public TrackingEventDetailsBuilder Set(string key, long value) + { + this._attributes.Set(key, value); + return this; + } + + /// + /// Set the key to the given bool. + /// + /// The key for the value + /// The value to set + /// This builder + public TrackingEventDetailsBuilder Set(string key, bool value) + { + this._attributes.Set(key, value); + return this; + } + + /// + /// Set the key to the given . + /// + /// The key for the value + /// The value to set + /// This builder + public TrackingEventDetailsBuilder Set(string key, Structure value) + { + this._attributes.Set(key, value); + return this; + } + + /// + /// Set the key to the given DateTime. + /// + /// The key for the value + /// The value to set + /// This builder + public TrackingEventDetailsBuilder Set(string key, DateTime value) + { + this._attributes.Set(key, value); + return this; + } + + /// + /// Incorporate existing tracking details into the builder. + /// + /// Any existing keys in the builder will be replaced by keys in the tracking details. + /// + /// + /// The tracking details to add merge + /// This builder + public TrackingEventDetailsBuilder Merge(TrackingEventDetails trackingDetails) + { + foreach (var kvp in trackingDetails) + { + this.Set(kvp.Key, kvp.Value); + } + + return this; + } + + /// + /// Build an immutable . + /// + /// An immutable + public TrackingEventDetails Build() + { + return new TrackingEventDetails(this._attributes.Build(), this._value); + } + } +} diff --git a/src/OpenFeature/OpenFeatureClient.cs b/src/OpenFeature/OpenFeatureClient.cs index c2621785..bbaecfb7 100644 --- a/src/OpenFeature/OpenFeatureClient.cs +++ b/src/OpenFeature/OpenFeatureClient.cs @@ -367,6 +367,31 @@ private async Task TriggerFinallyHooksAsync(IReadOnlyList hooks, HookCo } } + /// + /// Use this method to track user interactions and the application state. + /// + /// The name associated with this tracking event + /// The evaluation context used in the evaluation of the flag (optional) + /// Data pertinent to the tracking event (Optional) + /// When trackingEventName is null or empty + public void Track(string trackingEventName, EvaluationContext? evaluationContext = default, TrackingEventDetails? trackingEventDetails = default) + { + if (string.IsNullOrEmpty(trackingEventName)) + { + throw new ArgumentException(nameof(trackingEventName) + " cannot be null or empty."); + } + + var globalContext = Api.Instance.GetContext(); + var clientContext = this.GetContext(); + + var evaluationContextBuilder = EvaluationContext.Builder() + .Merge(globalContext) + .Merge(clientContext); + if (evaluationContext != null) evaluationContextBuilder.Merge(evaluationContext); + + this._providerAccessor.Invoke().Track(trackingEventName, evaluationContextBuilder.Build(), trackingEventDetails); + } + [LoggerMessage(100, LogLevel.Debug, "Hook {HookName} returned null, nothing to merge back into context")] partial void HookReturnedNull(string hookName); diff --git a/test/OpenFeature.Tests/OpenFeatureClientTests.cs b/test/OpenFeature.Tests/OpenFeatureClientTests.cs index ee9eee0c..cf110b5e 100644 --- a/test/OpenFeature.Tests/OpenFeatureClientTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureClientTests.cs @@ -540,5 +540,101 @@ public void ToFlagEvaluationDetails_Should_Convert_All_Properties() result.Should().BeEquivalentTo(expected); } + + [Fact] + [Specification("6.1.1", "The `client` MUST define a function for tracking the occurrence of a particular action or application state, with parameters `tracking event name` (string, required), `evaluation context` (optional) and `tracking event details` (optional), which returns nothing.")] + [Specification("6.1.2", "The `client` MUST define a function for tracking the occurrence of a particular action or application state, with parameters `tracking event name` (string, required) and `tracking event details` (optional), which returns nothing.")] + [Specification("6.1.4", "If the client's `track` function is called and the associated provider does not implement tracking, the client's `track` function MUST no-op.")] + public async Task TheClient_ImplementsATrackingFunction() + { + var provider = new TestProvider(); + await Api.Instance.SetProviderAsync(provider); + var client = Api.Instance.GetClient(); + + const string trackingEventName = "trackingEventName"; + var trackingEventDetails = new TrackingEventDetailsBuilder().Build(); + client.Track(trackingEventName); + client.Track(trackingEventName, EvaluationContext.Empty); + client.Track(trackingEventName, EvaluationContext.Empty, trackingEventDetails); + client.Track(trackingEventName, trackingEventDetails: trackingEventDetails); + + Assert.Equal(4, provider.GetTrackingInvocations().Count); + Assert.Equal(trackingEventName, provider.GetTrackingInvocations()[0].Item1); + Assert.Equal(trackingEventName, provider.GetTrackingInvocations()[1].Item1); + Assert.Equal(trackingEventName, provider.GetTrackingInvocations()[2].Item1); + Assert.Equal(trackingEventName, provider.GetTrackingInvocations()[3].Item1); + + Assert.NotNull(provider.GetTrackingInvocations()[0].Item2); + Assert.NotNull(provider.GetTrackingInvocations()[1].Item2); + Assert.NotNull(provider.GetTrackingInvocations()[2].Item2); + Assert.NotNull(provider.GetTrackingInvocations()[3].Item2); + + Assert.Equal(EvaluationContext.Empty.Count, provider.GetTrackingInvocations()[0].Item2!.Count); + Assert.Equal(EvaluationContext.Empty.Count, provider.GetTrackingInvocations()[1].Item2!.Count); + Assert.Equal(EvaluationContext.Empty.Count, provider.GetTrackingInvocations()[2].Item2!.Count); + Assert.Equal(EvaluationContext.Empty.Count, provider.GetTrackingInvocations()[3].Item2!.Count); + + Assert.Equal(EvaluationContext.Empty.TargetingKey, provider.GetTrackingInvocations()[0].Item2!.TargetingKey); + Assert.Equal(EvaluationContext.Empty.TargetingKey, provider.GetTrackingInvocations()[1].Item2!.TargetingKey); + Assert.Equal(EvaluationContext.Empty.TargetingKey, provider.GetTrackingInvocations()[2].Item2!.TargetingKey); + Assert.Equal(EvaluationContext.Empty.TargetingKey, provider.GetTrackingInvocations()[3].Item2!.TargetingKey); + + Assert.Null(provider.GetTrackingInvocations()[0].Item3); + Assert.Null(provider.GetTrackingInvocations()[1].Item3); + Assert.Equal(trackingEventDetails, provider.GetTrackingInvocations()[2].Item3); + Assert.Equal(trackingEventDetails, provider.GetTrackingInvocations()[3].Item3); + } + + public static TheoryData GenerateMergeEvaluationContextTestData() + { + const string key = "key"; + const string global = "global"; + const string client = "client"; + const string invocation = "invocation"; + var globalEvaluationContext = new[] { new EvaluationContextBuilder().Set(key, "global").Build(), null }; + var clientEvaluationContext = new[] { new EvaluationContextBuilder().Set(key, "client").Build(), null }; + var invocationEvaluationContext = new[] { new EvaluationContextBuilder().Set(key, "invocation").Build(), null }; + + var data = new TheoryData(); + for (int i = 0; i < 2; i++) + { + for (int j = 0; j < 2; j++) + { + for (int k = 0; k < 2; k++) + { + if (i == 1 && j == 1 && k == 1) continue; + string expected; + if (k == 0) expected = invocation; + else if (j == 0) expected = client; + else expected = global; + data.Add(key, globalEvaluationContext[i], clientEvaluationContext[j], invocationEvaluationContext[k], expected); + } + } + } + + return data; + } + + [Theory] + [MemberData(nameof(GenerateMergeEvaluationContextTestData))] + [Specification("6.1.3", "The evaluation context passed to the provider's track function MUST be merged in the order: API (global; lowest precedence) - transaction - client - invocation (highest precedence), with duplicate values being overwritten.")] + public async Task TheClient_MergesTheEvaluationContextInTheCorrectOrder(string key, EvaluationContext? globalEvaluationContext, EvaluationContext? clientEvaluationContext, EvaluationContext? invocationEvaluationContext, string expectedResult) + { + var provider = new TestProvider(); + await Api.Instance.SetProviderAsync(provider); + var client = Api.Instance.GetClient(); + + const string trackingEventName = "trackingEventName"; + + Api.Instance.SetContext(globalEvaluationContext); + client.SetContext(clientEvaluationContext); + client.Track(trackingEventName, invocationEvaluationContext); + Assert.Single(provider.GetTrackingInvocations()); + var actualEvaluationContext = provider.GetTrackingInvocations()[0].Item2; + Assert.NotNull(actualEvaluationContext); + Assert.NotEqual(0, actualEvaluationContext.Count); + + Assert.Equal(expectedResult, actualEvaluationContext.GetValue(key).AsString); + } } } diff --git a/test/OpenFeature.Tests/TestImplementations.cs b/test/OpenFeature.Tests/TestImplementations.cs index ea35b870..aa4dc784 100644 --- a/test/OpenFeature.Tests/TestImplementations.cs +++ b/test/OpenFeature.Tests/TestImplementations.cs @@ -60,6 +60,7 @@ public class TestProvider : FeatureProvider private readonly List _hooks = new List(); public static string DefaultName = "test-provider"; + private readonly List> TrackingInvocations = []; public string? Name { get; set; } @@ -87,6 +88,16 @@ public TestProvider(string? name, Exception? initException = null, int initDelay this.initDelay = initDelay; } + public ImmutableList> GetTrackingInvocations() + { + return this.TrackingInvocations.ToImmutableList(); + } + + public void Reset() + { + this.TrackingInvocations.Clear(); + } + public override Metadata GetMetadata() { return new Metadata(this.Name); @@ -131,6 +142,11 @@ public override async Task InitializeAsync(EvaluationContext context, Cancellati } } + public override void Track(string trackingEventName, EvaluationContext? evaluationContext = default, TrackingEventDetails? trackingEventDetails = default) + { + this.TrackingInvocations.Add(new Tuple(trackingEventName, evaluationContext, trackingEventDetails)); + } + internal ValueTask SendEventAsync(ProviderEventTypes eventType, CancellationToken cancellationToken = default) { return this.EventChannel.Writer.WriteAsync(new ProviderEventPayload { Type = eventType, ProviderName = this.GetMetadata().Name, }, cancellationToken); diff --git a/test/OpenFeature.Tests/TrackingEventDetailsTest.cs b/test/OpenFeature.Tests/TrackingEventDetailsTest.cs new file mode 100644 index 00000000..e566d04c --- /dev/null +++ b/test/OpenFeature.Tests/TrackingEventDetailsTest.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using OpenFeature.Model; +using OpenFeature.Tests.Internal; +using Xunit; + +namespace OpenFeature.Tests; + +public class TrackingEventDetailsTest +{ + [Fact] + [Specification("6.2.1", "The `tracking event details` structure MUST define an optional numeric `value`, associating a scalar quality with an `tracking event`.")] + public void TrackingEventDetails_HasAnOptionalValueProperty() + { + var builder = new TrackingEventDetailsBuilder(); + var details = builder.Build(); + Assert.Null(details.Value); + } + + [Fact] + [Specification("6.2.1", "The `tracking event details` structure MUST define an optional numeric `value`, associating a scalar quality with an `tracking event`.")] + public void TrackingEventDetails_HasAValueProperty() + { + const double value = 23.5; + var builder = new TrackingEventDetailsBuilder().SetValue(value); + var details = builder.Build(); + Assert.Equal(value, details.Value); + } + + [Fact] + [Specification("6.2.2", "The `tracking event details` MUST support the inclusion of custom fields, having keys of type `string`, and values of type `boolean | string | number | structure`.")] + public void TrackingEventDetails_CanTakeValues() + { + var structure = new Structure(new Dictionary { { "key", new Value("value") } }); + var builder = new TrackingEventDetailsBuilder() + .Set("boolean", true) + .Set("string", "some string") + .Set("number", 123.3) + .Set("structure", structure); + var details = builder.Build(); + Assert.Equal(true, details.GetValue("boolean").AsBoolean); + Assert.Equal("some string", details.GetValue("string").AsString); + Assert.Equal(123.3, details.GetValue("number").AsDouble); + Assert.Equal(structure, details.GetValue("structure").AsStructure); + } +}