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);
+ }
+}