Skip to content

Commit

Permalink
[FEATURE] Implement Tracking in .NET #309
Browse files Browse the repository at this point in the history
Signed-off-by: christian.lutnik <[email protected]>
  • Loading branch information
chrfwow committed Dec 4, 2024
1 parent c444e52 commit a289db9
Show file tree
Hide file tree
Showing 8 changed files with 505 additions and 1 deletion.
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:**
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>
/// Use this method to track user interactions and the application state. 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.
}
}
}
129 changes: 129 additions & 0 deletions src/OpenFeature/Model/TrackingEventDetails.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
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>
/// The index for the "targeting key" property when the EvaluationContext is serialized or expressed as a dictionary.
/// </summary>
internal const string TargetingKeyIndex = "targetingKey";

/// <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>
/// Returns the targeting key for the context.
/// </summary>
public string? TargetingKey
{
get
{
this._structure.TryGetValue(TargetingKeyIndex, out Value? targetingKey);
return targetingKey?.AsString;
}
}

/// <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();
}
}
168 changes: 168 additions & 0 deletions src/OpenFeature/Model/TrackingEventDetailsBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
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 targeting key for the tracking details.
/// </summary>
/// <param name="targetingKey">The targeting key</param>
/// <returns>This builder</returns>
public TrackingEventDetailsBuilder SetTargetingKey(string targetingKey)
{
this._attributes.Set(TrackingEventDetails.TargetingKeyIndex, targetingKey);
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.
/// </para>
/// </summary>
/// <param name="trackingDetails">The tracking details to add merge</param>
/// <returns>This builder</returns>
public TrackingEventDetailsBuilder Merge(TrackingEventDetails trackingDetails)
{
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.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);

Expand Down
Loading

0 comments on commit a289db9

Please sign in to comment.