Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add support for hook data. #387

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions src/OpenFeature/HookData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using OpenFeature.Model;

namespace OpenFeature
{
/// <summary>
/// A key-value collection of strings to objects used for passing data between hook stages.
/// <para>
/// This collection is scoped to a single evaluation for a single hook. Each hook stage for the evaluation
/// will share the same <see cref="HookData"/>.
/// </para>
/// <para>
/// This collection is intended for use only during the execution of individual hook stages, a reference
/// to the collection should not be retained.
/// </para>
/// <para>
/// This collection is not thread-safe.
/// </para>
/// </summary>
/// <seealso href="https://github.com/open-feature/spec/blob/main/specification/sections/04-hooks.md#46-hook-data"/>
public sealed class HookData
{
private readonly Dictionary<string, object> _data = [];

/// <summary>
/// Set the key to the given value.
/// </summary>
/// <param name="key">The key for the value</param>
/// <param name="value">The value to set</param>
public void Set(string key, object value)
{
this._data[key] = value;
}

/// <summary>
/// Gets the value at the specified key as an object.
/// <remarks>
/// For <see cref="Value"/> types use <see cref="Get"/> instead.
/// </remarks>
/// </summary>
/// <param name="key">The key of the value to be retrieved</param>
/// <returns>The object 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 object Get(string key)
{
return this._data[key];
}

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

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

/// <summary>
/// Return a list containing all the keys in the hook data
/// </summary>
public IImmutableList<string> Keys => this._data.Keys.ToImmutableList();

/// <summary>
/// Return an enumerable containing all the values of the hook data
/// </summary>
public IImmutableList<object> Values => this._data.Values.ToImmutableList();

/// <summary>
/// Gets all values as a read only dictionary.
/// <remarks>
/// The dictionary references the original values and is not a thread-safe copy.
/// </remarks>
/// </summary>
/// <returns>A <see cref="IDictionary{TKey,TValue}"/> representation of the hook data</returns>
public IReadOnlyDictionary<string, object> AsDictionary()
{
return this._data;
}
}
}
173 changes: 173 additions & 0 deletions src/OpenFeature/HookRunner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using OpenFeature.Model;

namespace OpenFeature
{
/// <summary>
/// This class manages the execution of hooks.
/// </summary>
/// <typeparam name="T">type of the evaluation detail provided to the hooks</typeparam>
internal partial class HookRunner<T>
{
private readonly ImmutableList<Hook> _hooks;

private readonly List<HookContext<T>> _hookContexts;

private EvaluationContext _evaluationContext;

private readonly ILogger _logger;

/// <summary>
/// Construct a hook runner instance. Each instance should be used for the execution of a single evaluation.
/// </summary>
/// <param name="hooks">
/// The hooks for the evaluation, these should be in the correct order for the before evaluation stage
/// </param>
/// <param name="evaluationContext">
/// The initial evaluation context, this can be updates as the hooks execute
/// </param>
/// <param name="sharedHookContext">
/// Contents of the initial hook context excluding the evaluation context and hook data
/// </param>
/// <param name="logger">Client logger instance</param>
public HookRunner(ImmutableList<Hook> hooks, EvaluationContext evaluationContext,
SharedHookContext<T> sharedHookContext,
ILogger logger)
{
this._evaluationContext = evaluationContext;
this._logger = logger;
this._hooks = hooks;
this._hookContexts = new List<HookContext<T>>(hooks.Count);
for (var i = 0; i < hooks.Count; i++)
{
// Create hook instance specific hook context.
// Hook contexts are instance specific so that the mutable hook data is scoped to each hook.
this._hookContexts.Add(sharedHookContext.ToHookContext(evaluationContext));
}
}

/// <summary>
/// Execute before hooks.
/// </summary>
/// <param name="hints">Optional hook hints</param>
/// <param name="cancellationToken">Cancellation token which can cancel hook operations</param>
/// <returns>Context with any modifications from the before hooks</returns>
public async Task<EvaluationContext> TriggerBeforeHooksAsync(IImmutableDictionary<string, object>? hints,
CancellationToken cancellationToken = default)
{
var evalContextBuilder = EvaluationContext.Builder();
evalContextBuilder.Merge(this._evaluationContext);

for (var i = 0; i < this._hooks.Count; i++)
{
var hook = this._hooks[i];
var hookContext = this._hookContexts[i];

var resp = await hook.BeforeAsync(hookContext, hints, cancellationToken)
.ConfigureAwait(false);
if (resp != null)
{
evalContextBuilder.Merge(resp);
this._evaluationContext = evalContextBuilder.Build();
for (var j = 0; j < this._hookContexts.Count; j++)
{
this._hookContexts[j] = this._hookContexts[j].WithNewEvaluationContext(this._evaluationContext);
}
}
else
{
this.HookReturnedNull(hook.GetType().Name);
}
}

return this._evaluationContext;
}

/// <summary>
/// Execute the after hooks. These are executed in opposite order of the before hooks.
/// </summary>
/// <param name="evaluationDetails">The evaluation details which will be provided to the hook</param>
/// <param name="hints">Optional hook hints</param>
/// <param name="cancellationToken">Cancellation token which can cancel hook operations</param>
public async Task TriggerAfterHooksAsync(FlagEvaluationDetails<T> evaluationDetails,
IImmutableDictionary<string, object>? hints,
CancellationToken cancellationToken = default)
{
// After hooks run in reverse.
for (var i = this._hooks.Count - 1; i >= 0; i--)
{
var hook = this._hooks[i];
var hookContext = this._hookContexts[i];
await hook.AfterAsync(hookContext, evaluationDetails, hints, cancellationToken)
.ConfigureAwait(false);
}
}

/// <summary>
/// Execute the error hooks. These are executed in opposite order of the before hooks.
/// </summary>
/// <param name="exception">Exception which triggered the error</param>
/// <param name="hints">Optional hook hints</param>
/// <param name="cancellationToken">Cancellation token which can cancel hook operations</param>
public async Task TriggerErrorHooksAsync(Exception exception,
IImmutableDictionary<string, object>? hints, CancellationToken cancellationToken = default)
{
// Error hooks run in reverse.
for (var i = this._hooks.Count - 1; i >= 0; i--)
{
var hook = this._hooks[i];
var hookContext = this._hookContexts[i];
try
{
await hook.ErrorAsync(hookContext, exception, hints, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception e)
{
this.ErrorHookError(hook.GetType().Name, e);
}

Check warning on line 133 in src/OpenFeature/HookRunner.cs

View check run for this annotation

Codecov / codecov/patch

src/OpenFeature/HookRunner.cs#L130-L133

Added lines #L130 - L133 were not covered by tests
}
}

/// <summary>
/// Execute the finally hooks. These are executed in opposite order of the before hooks.
/// </summary>
/// <param name="evaluationDetails">The evaluation details which will be provided to the hook</param>
/// <param name="hints">Optional hook hints</param>
/// <param name="cancellationToken">Cancellation token which can cancel hook operations</param>
public async Task TriggerFinallyHooksAsync(FlagEvaluationDetails<T> evaluationDetails,
IImmutableDictionary<string, object>? hints,
CancellationToken cancellationToken = default)
{
// Finally hooks run in reverse
for (var i = this._hooks.Count - 1; i >= 0; i--)
{
var hook = this._hooks[i];
var hookContext = this._hookContexts[i];
try
{
await hook.FinallyAsync(hookContext, evaluationDetails, hints, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception e)
{
this.FinallyHookError(hook.GetType().Name, e);
}
}
}

[LoggerMessage(100, LogLevel.Debug, "Hook {HookName} returned null, nothing to merge back into context")]
partial void HookReturnedNull(string hookName);

[LoggerMessage(103, LogLevel.Error, "Error while executing Error hook {HookName}")]
partial void ErrorHookError(string hookName, Exception exception);

[LoggerMessage(104, LogLevel.Error, "Error while executing Finally hook {HookName}")]
partial void FinallyHookError(string hookName, Exception exception);
}
}
43 changes: 27 additions & 16 deletions src/OpenFeature/Model/HookContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,22 @@ namespace OpenFeature.Model
/// <seealso href="https://github.com/open-feature/spec/blob/v0.5.2/specification/sections/04-hooks.md#41-hook-context"/>
public sealed class HookContext<T>
{
private readonly SharedHookContext<T> _shared;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given the combination of evaluation context updates with before hooks, and mutable hook data per hook-instance, the number of potential copies increases. I moved most of the hook context into this shared component. Then the per instance contexts are just referencing the same data.

This also made it easier to not instantiate a extra HookData. It could be moved back, and the first instance used for the first hook as an alternative to this approach.


/// <summary>
/// Feature flag being evaluated
/// </summary>
public string FlagKey { get; }
public string FlagKey => this._shared.FlagKey;

/// <summary>
/// Default value if flag fails to be evaluated
/// </summary>
public T DefaultValue { get; }
public T DefaultValue => this._shared.DefaultValue;

/// <summary>
/// The value type of the flag
/// </summary>
public FlagValueType FlagValueType { get; }
public FlagValueType FlagValueType => this._shared.FlagValueType;

/// <summary>
/// User defined evaluation context used in the evaluation process
Expand All @@ -34,12 +36,17 @@ public sealed class HookContext<T>
/// <summary>
/// Client metadata
/// </summary>
public ClientMetadata ClientMetadata { get; }
public ClientMetadata ClientMetadata => this._shared.ClientMetadata;

/// <summary>
/// Provider metadata
/// </summary>
public Metadata ProviderMetadata { get; }
public Metadata ProviderMetadata => this._shared.ProviderMetadata;

/// <summary>
/// Hook data
/// </summary>
public HookData Data { get; }

/// <summary>
/// Initialize a new instance of <see cref="HookContext{T}"/>
Expand All @@ -58,23 +65,27 @@ public HookContext(string? flagKey,
Metadata? providerMetadata,
EvaluationContext? evaluationContext)
{
this.FlagKey = flagKey ?? throw new ArgumentNullException(nameof(flagKey));
this.DefaultValue = defaultValue;
this.FlagValueType = flagValueType;
this.ClientMetadata = clientMetadata ?? throw new ArgumentNullException(nameof(clientMetadata));
this.ProviderMetadata = providerMetadata ?? throw new ArgumentNullException(nameof(providerMetadata));
this._shared = new SharedHookContext<T>(
flagKey, defaultValue, flagValueType, clientMetadata, providerMetadata);

this.EvaluationContext = evaluationContext ?? throw new ArgumentNullException(nameof(evaluationContext));
this.Data = new HookData();
}

internal HookContext(SharedHookContext<T>? sharedHookContext, EvaluationContext? evaluationContext,
HookData? hookData)
{
this._shared = sharedHookContext ?? throw new ArgumentNullException(nameof(sharedHookContext));
this.EvaluationContext = evaluationContext ?? throw new ArgumentNullException(nameof(evaluationContext));
this.Data = hookData ?? throw new ArgumentNullException(nameof(hookData));
}

internal HookContext<T> WithNewEvaluationContext(EvaluationContext context)
{
return new HookContext<T>(
this.FlagKey,
this.DefaultValue,
this.FlagValueType,
this.ClientMetadata,
this.ProviderMetadata,
context
this._shared,
context,
this.Data
);
}
}
Expand Down
Loading