Skip to content

Commit

Permalink
feat: Implement Tracking in .NET #309 (#327)
Browse files Browse the repository at this point in the history
## This PR
Adds support for tracking 

### Related Issues

Closes #309

---------

Signed-off-by: christian.lutnik <[email protected]>
  • Loading branch information
chrfwow authored Dec 11, 2024
1 parent 70f847b commit cbf4f25
Show file tree
Hide file tree
Showing 8 changed files with 505 additions and 1 deletion.
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ public async Task Example()
|| [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. |
|| [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). |
|| [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. |
|| [Tracking](#tracking) | Associate user actions with feature flag evaluations. |
|| [Logging](#logging) | Integrate with popular logging packages. |
|| [Domains](#domains) | Logically bind clients with providers. |
|| [Eventing](#eventing) | React to state changes in the provider or flag management system. |
Expand Down Expand Up @@ -212,6 +213,19 @@ await Api.Instance.SetProviderAsync(myClient.GetMetadata().Name, provider);
myClient.AddHandler(ProviderEventTypes.ProviderReady, callback);
```

### Tracking

The [tracking API](https://openfeature.dev/specification/sections/tracking) 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.
Expand Down Expand Up @@ -320,7 +334,7 @@ builder.Services.AddOpenFeature(featureBuilder => {
featureBuilder
.AddHostedFeatureLifecycle() // From Hosting package
.AddContext((contextBuilder, serviceProvider) => { /* Custom context configuration */ })
.AddInMemoryProvider();
.AddInMemoryProvider();
});
```
**Domain-Scoped Provider Configuration:**
Expand Down
12 changes: 12 additions & 0 deletions src/OpenFeature/FeatureProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using OpenFeature.Model;

[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // required to allow NSubstitute mocking of internal methods

namespace OpenFeature
{
/// <summary>
Expand Down Expand Up @@ -140,5 +141,16 @@ public virtual Task ShutdownAsync(CancellationToken cancellationToken = default)
/// </summary>
/// <returns>The event channel of the provider</returns>
public virtual Channel<object> GetEventChannel() => this.EventChannel;

/// <summary>
/// Track a user action or application state, usually representing a business objective or outcome. The implementation of this method is optional.
/// </summary>
/// <param name="trackingEventName">The name associated with this tracking event</param>
/// <param name="evaluationContext">The evaluation context used in the evaluation of the flag (optional)</param>
/// <param name="trackingEventDetails">Data pertinent to the tracking event (Optional)</param>
public virtual void Track(string trackingEventName, EvaluationContext? evaluationContext = default, TrackingEventDetails? trackingEventDetails = default)
{
// Intentionally left blank.
}
}
}
112 changes: 112 additions & 0 deletions src/OpenFeature/Model/TrackingEventDetails.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;

namespace OpenFeature.Model;

/// <summary>
/// The `tracking event details` structure defines optional data pertinent to a particular `tracking event`.
/// </summary>
/// <seealso href="https://github.com/open-feature/spec/blob/main/specification/sections/06-tracking.md#62-tracking-event-details"/>
public sealed class TrackingEventDetails
{
/// <summary>
///A predefined value field for the tracking details.
/// </summary>
public readonly double? Value;

private readonly Structure _structure;

/// <summary>
/// Internal constructor used by the builder.
/// </summary>
/// <param name="content"></param>
/// <param name="value"></param>
internal TrackingEventDetails(Structure content, double? value)
{
this.Value = value;
this._structure = content;
}


/// <summary>
/// Private constructor for making an empty <see cref="TrackingEventDetails"/>.
/// </summary>
private TrackingEventDetails()
{
this._structure = Structure.Empty;
this.Value = null;
}

/// <summary>
/// Empty tracking event details.
/// </summary>
public static TrackingEventDetails Empty { get; } = new();


/// <summary>
/// Gets the Value at the specified key
/// </summary>
/// <param name="key">The key of the value to be retrieved</param>
/// <returns>The <see cref="Model.Value"/> associated with the key</returns>
/// <exception cref="KeyNotFoundException">
/// Thrown when the context does not contain the specified key
/// </exception>
/// <exception cref="ArgumentNullException">
/// Thrown when the key is <see langword="null" />
/// </exception>
public Value GetValue(string key) => this._structure.GetValue(key);

/// <summary>
/// Bool indicating if the specified key exists in the evaluation context
/// </summary>
/// <param name="key">The key of the value to be checked</param>
/// <returns><see cref="bool" />indicating the presence of the key</returns>
/// <exception cref="ArgumentNullException">
/// Thrown when the key is <see langword="null" />
/// </exception>
public bool ContainsKey(string key) => this._structure.ContainsKey(key);

/// <summary>
/// Gets the value associated with the specified key
/// </summary>
/// <param name="value">The <see cref="Model.Value"/> or <see langword="null" /> if the key was not present</param>
/// <param name="key">The key of the value to be retrieved</param>
/// <returns><see cref="bool" />indicating the presence of the key</returns>
/// <exception cref="ArgumentNullException">
/// Thrown when the key is <see langword="null" />
/// </exception>
public bool TryGetValue(string key, out Value? value) => this._structure.TryGetValue(key, out value);

/// <summary>
/// Gets all values as a Dictionary
/// </summary>
/// <returns>New <see cref="IDictionary{TKey,TValue}"/> representation of this Structure</returns>
public IImmutableDictionary<string, Value> AsDictionary()
{
return this._structure.AsDictionary();
}

/// <summary>
/// Return a count of all values
/// </summary>
public int Count => this._structure.Count;

/// <summary>
/// Return an enumerator for all values
/// </summary>
/// <returns>An enumerator for all values</returns>
public IEnumerator<KeyValuePair<string, Value>> GetEnumerator()
{
return this._structure.GetEnumerator();
}

/// <summary>
/// Get a builder which can build an <see cref="EvaluationContext"/>.
/// </summary>
/// <returns>The builder</returns>
public static TrackingEventDetailsBuilder Builder()
{
return new TrackingEventDetailsBuilder();
}
}
159 changes: 159 additions & 0 deletions src/OpenFeature/Model/TrackingEventDetailsBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
using System;

namespace OpenFeature.Model
{
/// <summary>
/// A builder which allows the specification of attributes for an <see cref="TrackingEventDetails"/>.
/// <para>
/// A <see cref="TrackingEventDetailsBuilder"/> object is intended for use by a single thread and should not be used
/// from multiple threads. Once an <see cref="TrackingEventDetails"/> has been created it is immutable and safe for use
/// from multiple threads.
/// </para>
/// </summary>
public sealed class TrackingEventDetailsBuilder
{
private readonly StructureBuilder _attributes = Structure.Builder();
private double? _value;

/// <summary>
/// Internal to only allow direct creation by <see cref="TrackingEventDetails.Builder()"/>.
/// </summary>
internal TrackingEventDetailsBuilder() { }

/// <summary>
/// Set the predefined value field for the tracking details.
/// </summary>
/// <param name="value"></param>
/// <returns></returns>
public TrackingEventDetailsBuilder SetValue(double? value)
{
this._value = value;
return this;
}

/// <summary>
/// Set the key to the given <see cref="Value"/>.
/// </summary>
/// <param name="key">The key for the value</param>
/// <param name="value">The value to set</param>
/// <returns>This builder</returns>
public TrackingEventDetailsBuilder Set(string key, Value value)
{
this._attributes.Set(key, value);
return this;
}

/// <summary>
/// Set the key to the given string.
/// </summary>
/// <param name="key">The key for the value</param>
/// <param name="value">The value to set</param>
/// <returns>This builder</returns>
public TrackingEventDetailsBuilder Set(string key, string value)
{
this._attributes.Set(key, value);
return this;
}

/// <summary>
/// Set the key to the given int.
/// </summary>
/// <param name="key">The key for the value</param>
/// <param name="value">The value to set</param>
/// <returns>This builder</returns>
public TrackingEventDetailsBuilder Set(string key, int value)
{
this._attributes.Set(key, value);
return this;
}

/// <summary>
/// Set the key to the given double.
/// </summary>
/// <param name="key">The key for the value</param>
/// <param name="value">The value to set</param>
/// <returns>This builder</returns>
public TrackingEventDetailsBuilder Set(string key, double value)
{
this._attributes.Set(key, value);
return this;
}

/// <summary>
/// Set the key to the given long.
/// </summary>
/// <param name="key">The key for the value</param>
/// <param name="value">The value to set</param>
/// <returns>This builder</returns>
public TrackingEventDetailsBuilder Set(string key, long value)
{
this._attributes.Set(key, value);
return this;
}

/// <summary>
/// Set the key to the given bool.
/// </summary>
/// <param name="key">The key for the value</param>
/// <param name="value">The value to set</param>
/// <returns>This builder</returns>
public TrackingEventDetailsBuilder Set(string key, bool value)
{
this._attributes.Set(key, value);
return this;
}

/// <summary>
/// Set the key to the given <see cref="Structure"/>.
/// </summary>
/// <param name="key">The key for the value</param>
/// <param name="value">The value to set</param>
/// <returns>This builder</returns>
public TrackingEventDetailsBuilder Set(string key, Structure value)
{
this._attributes.Set(key, value);
return this;
}

/// <summary>
/// Set the key to the given DateTime.
/// </summary>
/// <param name="key">The key for the value</param>
/// <param name="value">The value to set</param>
/// <returns>This builder</returns>
public TrackingEventDetailsBuilder Set(string key, DateTime value)
{
this._attributes.Set(key, value);
return this;
}

/// <summary>
/// Incorporate existing tracking details into the builder.
/// <para>
/// Any existing keys in the builder will be replaced by keys in the tracking details, including the Value set
/// through <see cref="SetValue(double?)"/>.
/// </para>
/// </summary>
/// <param name="trackingDetails">The tracking details to add merge</param>
/// <returns>This builder</returns>
public TrackingEventDetailsBuilder Merge(TrackingEventDetails trackingDetails)
{
this._value = trackingDetails.Value;
foreach (var kvp in trackingDetails)
{
this.Set(kvp.Key, kvp.Value);
}

return this;
}

/// <summary>
/// Build an immutable <see cref="TrackingEventDetails"/>.
/// </summary>
/// <returns>An immutable <see cref="TrackingEventDetails"/></returns>
public TrackingEventDetails Build()
{
return new TrackingEventDetails(this._attributes.Build(), this._value);
}
}
}
25 changes: 25 additions & 0 deletions src/OpenFeature/OpenFeatureClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,31 @@ private async Task TriggerFinallyHooksAsync<T>(IReadOnlyList<Hook> hooks, HookCo
}
}

/// <summary>
/// Use this method to track user interactions and the application state.
/// </summary>
/// <param name="trackingEventName">The name associated with this tracking event</param>
/// <param name="evaluationContext">The evaluation context used in the evaluation of the flag (optional)</param>
/// <param name="trackingEventDetails">Data pertinent to the tracking event (Optional)</param>
/// <exception cref="ArgumentException">When trackingEventName is null or empty</exception>
public void Track(string trackingEventName, EvaluationContext? evaluationContext = default, TrackingEventDetails? trackingEventDetails = default)
{
if (string.IsNullOrWhiteSpace(trackingEventName))
{
throw new ArgumentException("Tracking event cannot be null or empty.", nameof(trackingEventName));
}

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

Expand Down
Loading

0 comments on commit cbf4f25

Please sign in to comment.