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: Implement transaction context #312

Merged
merged 25 commits into from
Jan 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
264d8c9
Changing API client and added Context Propagator.
askpt Dec 10, 2024
c15192a
Merge branch 'main' into askpt/243-feature-implement-transaction-context
askpt Dec 12, 2024
3539674
Merge branch 'main' into askpt/243-feature-implement-transaction-context
askpt Dec 13, 2024
63c3f62
Merge branch 'main' into askpt/243-feature-implement-transaction-context
askpt Dec 18, 2024
6a870ff
Adding NoOpTransactionContextPropagator.
askpt Dec 18, 2024
8bcfc99
Making field readonly.
askpt Dec 18, 2024
50613fe
Added an empty context return.
askpt Dec 18, 2024
b67b659
Adding unit tests.
askpt Dec 18, 2024
b0a20d7
Merge branch 'main' into askpt/243-feature-implement-transaction-context
askpt Dec 18, 2024
02f93dd
Fix unit test.
askpt Dec 18, 2024
8d45264
Adding clear transaction context to shutdown.
askpt Dec 18, 2024
8e3789b
Merge branch 'main' into askpt/243-feature-implement-transaction-context
askpt Dec 19, 2024
d9ed730
Merging transaction context at evaluation.
askpt Dec 19, 2024
9f75a44
Adding an AsyncLocalTransactionContextPropagator.
askpt Dec 19, 2024
e473ccc
Merge branch 'main' into askpt/243-feature-implement-transaction-context
askpt Dec 19, 2024
4146f0e
Adding unit tests.
askpt Dec 20, 2024
64f5c68
Adding missing tests.
askpt Dec 20, 2024
f461152
fixup: add docs
toddbaert Dec 31, 2024
bf4dbb4
Change visibility of GetPropagator.
askpt Dec 31, 2024
e480050
Merge branch 'main' into askpt/243-feature-implement-transaction-context
askpt Dec 31, 2024
7a60997
Fixed comment.
askpt Jan 2, 2025
1769be8
Replaced the context.
askpt Jan 2, 2025
59ddca7
Fixing the context merging order. Updated unit test.
askpt Jan 2, 2025
6d2d318
Merge branch 'main' into askpt/243-feature-implement-transaction-context
askpt Jan 6, 2025
95c1fb7
Merge branch 'main' into askpt/243-feature-implement-transaction-context
askpt Jan 6, 2025
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
47 changes: 35 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,18 +68,19 @@ public async Task Example()

## 🌟 Features

| Status | Features | Description |
| ------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| ✅ | [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. |
| ✅ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
| ✅ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
| 🔬 | [DependencyInjection](#DependencyInjection) | Integrate OpenFeature with .NET's dependency injection for streamlined provider setup. |
| Status | Features | Description |
| ------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ✅ | [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. |
| ✅ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
| ✅ | [Transaction Context Propagation](#transaction-context-propagation) | Set a specific [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) for a transaction (e.g. an HTTP request or a thread). |
| ✅ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
| 🔬 | [DependencyInjection](#DependencyInjection) | Integrate OpenFeature with .NET's dependency injection for streamlined provider setup. |

> Implemented: ✅ | In-progress: ⚠️ | Not implemented yet: ❌ | Experimental: 🔬

Expand Down Expand Up @@ -234,6 +235,28 @@ The OpenFeature API provides a close function to perform a cleanup of all regist
await Api.Instance.ShutdownAsync();
```

### Transaction Context Propagation

Transaction context is a container for transaction-specific evaluation context (e.g. user id, user agent, IP).
Transaction context can be set where specific data is available (e.g. an auth service or request handler) and by using the transaction context propagator it will automatically be applied to all flag evaluations within a transaction (e.g. a request or thread).
By default, the `NoOpTransactionContextPropagator` is used, which doesn't store anything.
To register a [AsyncLocal](https://learn.microsoft.com/en-us/dotnet/api/system.threading.asynclocal-1) context propagator, you can use the `SetTransactionContextPropagator` method as shown below.

```csharp
// registering the AsyncLocalTransactionContextPropagator
Api.Instance.SetTransactionContextPropagator(new AsyncLocalTransactionContextPropagator());
beeme1mr marked this conversation as resolved.
Show resolved Hide resolved
```
Once you've registered a transaction context propagator, you can propagate the data into request-scoped transaction context.

```csharp
// adding userId to transaction context
EvaluationContext transactionContext = EvaluationContext.Builder()
.Set("userId", userId)
.Build();
Api.Instance.SetTransactionContext(transactionContext);
```
Additionally, you can develop a custom transaction context propagator by implementing the `TransactionContextPropagator` interface and registering it as shown above.

## Extending

### Develop a provider
Expand Down
59 changes: 57 additions & 2 deletions src/OpenFeature/Api.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ public sealed class Api : IEventBus
private EventExecutor _eventExecutor = new EventExecutor();
private ProviderRepository _repository = new ProviderRepository();
private readonly ConcurrentStack<Hook> _hooks = new ConcurrentStack<Hook>();
private ITransactionContextPropagator _transactionContextPropagator = new NoOpTransactionContextPropagator();
private readonly object _transactionContextPropagatorLock = new();

/// The reader/writer locks are not disposed because the singleton instance should never be disposed.
private readonly ReaderWriterLockSlim _evaluationContextLock = new ReaderWriterLockSlim();
Expand All @@ -47,6 +49,7 @@ public async Task SetProviderAsync(FeatureProvider featureProvider)
{
this._eventExecutor.RegisterDefaultFeatureProvider(featureProvider);
await this._repository.SetProviderAsync(featureProvider, this.GetContext(), this.AfterInitialization, this.AfterError).ConfigureAwait(false);

}

/// <summary>
Expand Down Expand Up @@ -85,7 +88,6 @@ public FeatureProvider GetProvider()
/// Gets the feature provider with given domain
/// </summary>
/// <param name="domain">An identifier which logically binds clients with providers</param>

/// <returns>A provider associated with the given domain, if domain is empty or doesn't
/// have a corresponding provider the default provider will be returned</returns>
public FeatureProvider GetProvider(string domain)
Expand All @@ -109,7 +111,6 @@ public FeatureProvider GetProvider(string domain)
/// assigned to it the default provider will be returned
/// </summary>
/// <param name="domain">An identifier which logically binds clients with providers</param>

/// <returns>Metadata assigned to provider</returns>
public Metadata? GetProviderMetadata(string domain) => this.GetProvider(domain).GetMetadata();

Expand Down Expand Up @@ -218,6 +219,59 @@ public EvaluationContext GetContext()
}
}

/// <summary>
/// Return the transaction context propagator.
/// </summary>
/// <returns><see cref="ITransactionContextPropagator"/>the registered transaction context propagator</returns>
internal ITransactionContextPropagator GetTransactionContextPropagator()
{
return this._transactionContextPropagator;
}

/// <summary>
/// Sets the transaction context propagator.
/// </summary>
/// <param name="transactionContextPropagator">the transaction context propagator to be registered</param>
/// <exception cref="ArgumentNullException">Transaction context propagator cannot be null</exception>
public void SetTransactionContextPropagator(ITransactionContextPropagator transactionContextPropagator)
{
if (transactionContextPropagator == null)
{
throw new ArgumentNullException(nameof(transactionContextPropagator),
"Transaction context propagator cannot be null");
}

lock (this._transactionContextPropagatorLock)
{
this._transactionContextPropagator = transactionContextPropagator;
}
}

/// <summary>
/// Returns the currently defined transaction context using the registered transaction context propagator.
/// </summary>
/// <returns><see cref="EvaluationContext"/>The current transaction context</returns>
public EvaluationContext GetTransactionContext()
{
return this._transactionContextPropagator.GetTransactionContext();
}

/// <summary>
/// Sets the transaction context using the registered transaction context propagator.
/// </summary>
/// <param name="evaluationContext">The <see cref="EvaluationContext"/> to set</param>
/// <exception cref="InvalidOperationException">Transaction context propagator is not set.</exception>
/// <exception cref="ArgumentNullException">Evaluation context cannot be null</exception>
public void SetTransactionContext(EvaluationContext evaluationContext)
{
if (evaluationContext == null)
{
throw new ArgumentNullException(nameof(evaluationContext), "Evaluation context cannot be null");
}

this._transactionContextPropagator.SetTransactionContext(evaluationContext);
}

/// <summary>
/// <para>
/// Shut down and reset the current status of OpenFeature API.
Expand All @@ -234,6 +288,7 @@ public async Task ShutdownAsync()
{
this._evaluationContext = EvaluationContext.Empty;
this._hooks.Clear();
this._transactionContextPropagator = new NoOpTransactionContextPropagator();

// TODO: make these lazy to avoid extra allocations on the common cleanup path?
this._eventExecutor = new EventExecutor();
Expand Down
25 changes: 25 additions & 0 deletions src/OpenFeature/AsyncLocalTransactionContextPropagator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.Threading;
using OpenFeature.Model;

namespace OpenFeature;

/// <summary>
/// This is a task transaction context implementation of <see cref="ITransactionContextPropagator"/>
/// It uses the <see cref="AsyncLocal{T}"/> to store the transaction context.
/// </summary>
public sealed class AsyncLocalTransactionContextPropagator : ITransactionContextPropagator
{
private readonly AsyncLocal<EvaluationContext> _transactionContext = new();

/// <inheritdoc />
public EvaluationContext GetTransactionContext()
{
return this._transactionContext.Value ?? EvaluationContext.Empty;
}

/// <inheritdoc />
public void SetTransactionContext(EvaluationContext evaluationContext)
{
this._transactionContext.Value = evaluationContext;
}
}
26 changes: 26 additions & 0 deletions src/OpenFeature/Model/ITransactionContextPropagator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace OpenFeature.Model;

/// <summary>
/// <see cref="ITransactionContextPropagator"/> is responsible for persisting a transactional context
/// for the duration of a single transaction.
/// Examples of potential transaction specific context include: a user id, user agent, IP.
/// Transaction context is merged with evaluation context prior to flag evaluation.
/// </summary>
/// <remarks>
/// The precedence of merging context can be seen in
/// <a href="https://openfeature.dev/specification/sections/evaluation-context#requirement-323">the specification</a>.
/// </remarks>
public interface ITransactionContextPropagator
{
/// <summary>
/// Returns the currently defined transaction context using the registered transaction context propagator.
/// </summary>
/// <returns><see cref="EvaluationContext"/>The current transaction context</returns>
EvaluationContext GetTransactionContext();

/// <summary>
/// Sets the transaction context.
/// </summary>
/// <param name="evaluationContext">The transaction context to be set</param>
void SetTransactionContext(EvaluationContext evaluationContext);
}
15 changes: 15 additions & 0 deletions src/OpenFeature/NoOpTransactionContextPropagator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using OpenFeature.Model;

namespace OpenFeature;

internal class NoOpTransactionContextPropagator : ITransactionContextPropagator
{
public EvaluationContext GetTransactionContext()
{
return EvaluationContext.Empty;
}

public void SetTransactionContext(EvaluationContext evaluationContext)
{
}
}
10 changes: 5 additions & 5 deletions src/OpenFeature/OpenFeatureClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -215,12 +215,12 @@ private async Task<FlagEvaluationDetails<T>> EvaluateFlagAsync<T>(
// New up an evaluation context if one was not provided.
context ??= EvaluationContext.Empty;

// merge api, client, and invocation context.
var evaluationContext = Api.Instance.GetContext();
// merge api, client, transaction and invocation context
var evaluationContextBuilder = EvaluationContext.Builder();
evaluationContextBuilder.Merge(evaluationContext);
evaluationContextBuilder.Merge(this.GetContext());
evaluationContextBuilder.Merge(context);
evaluationContextBuilder.Merge(Api.Instance.GetContext()); // API context
evaluationContextBuilder.Merge(this.GetContext()); // Client context
evaluationContextBuilder.Merge(Api.Instance.GetTransactionContext()); // Transaction context
evaluationContextBuilder.Merge(context); // Invocation context

var allHooks = new List<Hook>()
.Concat(Api.Instance.GetHooks())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using OpenFeature.Model;
using Xunit;

namespace OpenFeature.Tests;

public class AsyncLocalTransactionContextPropagatorTests
{
[Fact]
public void GetTransactionContext_ReturnsEmpty_WhenNoContextIsSet()
{
// Arrange
var propagator = new AsyncLocalTransactionContextPropagator();

// Act
var context = propagator.GetTransactionContext();

// Assert
Assert.Equal(EvaluationContext.Empty, context);
}

[Fact]
public void SetTransactionContext_SetsAndGetsContextCorrectly()
{
// Arrange
var propagator = new AsyncLocalTransactionContextPropagator();
var evaluationContext = EvaluationContext.Builder()
.Set("initial", "yes")
.Build();

// Act
propagator.SetTransactionContext(evaluationContext);
var context = propagator.GetTransactionContext();

// Assert
Assert.Equal(evaluationContext, context);
Assert.Equal(evaluationContext.GetValue("initial"), context.GetValue("initial"));
}

[Fact]
public void SetTransactionContext_OverridesPreviousContext()
{
// Arrange
var propagator = new AsyncLocalTransactionContextPropagator();

var initialContext = EvaluationContext.Builder()
.Set("initial", "yes")
.Build();
var newContext = EvaluationContext.Empty;

// Act
propagator.SetTransactionContext(initialContext);
propagator.SetTransactionContext(newContext);
var context = propagator.GetTransactionContext();

// Assert
Assert.Equal(newContext, context);
}
}
17 changes: 17 additions & 0 deletions test/OpenFeature.Tests/OpenFeatureHookTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,9 @@ public async Task Evaluation_Context_Must_Be_Merged_In_Correct_Order()
var propInvocation = "4.3.4invocation";
var propInvocationToOverwrite = "4.3.4invocationToOverwrite";

var propTransaction = "4.3.4transaction";
var propTransactionToOverwrite = "4.3.4transactionToOverwrite";

var propHook = "4.3.4hook";

// setup a cascade of overwriting properties
Expand All @@ -180,17 +183,29 @@ public async Task Evaluation_Context_Must_Be_Merged_In_Correct_Order()
.Set(propClientToOverwrite, false)
.Build();

var transactionContext = new EvaluationContextBuilder()
.Set(propTransaction, true)
.Set(propInvocationToOverwrite, true)
.Set(propTransactionToOverwrite, false)
.Build();

var invocationContext = new EvaluationContextBuilder()
.Set(propInvocation, true)
.Set(propClientToOverwrite, true)
.Set(propTransactionToOverwrite, true)
.Set(propInvocationToOverwrite, false)
.Build();


var hookContext = new EvaluationContextBuilder()
.Set(propHook, true)
.Set(propInvocationToOverwrite, true)
.Build();

var transactionContextPropagator = new AsyncLocalTransactionContextPropagator();
transactionContextPropagator.SetTransactionContext(transactionContext);
Api.Instance.SetTransactionContextPropagator(transactionContextPropagator);

var provider = Substitute.For<FeatureProvider>();

provider.GetMetadata().Returns(new Metadata(null));
Expand All @@ -212,7 +227,9 @@ public async Task Evaluation_Context_Must_Be_Merged_In_Correct_Order()
_ = provider.Received(1).ResolveBooleanValueAsync(Arg.Any<string>(), Arg.Any<bool>(), Arg.Is<EvaluationContext>(y =>
(y.GetValue(propGlobal).AsBoolean ?? false)
&& (y.GetValue(propClient).AsBoolean ?? false)
&& (y.GetValue(propTransaction).AsBoolean ?? false)
&& (y.GetValue(propGlobalToOverwrite).AsBoolean ?? false)
&& (y.GetValue(propTransactionToOverwrite).AsBoolean ?? false)
&& (y.GetValue(propInvocation).AsBoolean ?? false)
&& (y.GetValue(propClientToOverwrite).AsBoolean ?? false)
&& (y.GetValue(propHook).AsBoolean ?? false)
Expand Down
Loading
Loading