Skip to content

Commit 1b5a0a9

Browse files
askpttoddbaert
andauthored
feat: Implement transaction context (#312)
<!-- Please use this template for your pull request. --> <!-- Please use the sections that you need and delete other sections --> ## Transaction Context <!-- add the description of the PR here --> This pull request introduces transaction context propagation to the OpenFeature library. The changes include adding a new interface for transaction context propagation, implementing a no-op and an AsyncLocal-based propagator, and updating the API to support these propagators. ### Related Issues <!-- add here the GitHub issue that this PR resolves if applicable --> Fixes #243 ### Notes <!-- any additional notes for this PR --> #### Transaction Context Propagation: * [`src/OpenFeature/Api.cs`](diffhunk://#diff-dc150fbd3b7be797470374ee7e38c7ab8a31eb9b6b7a52345e05114c2d32a15eR25-R26): Added a private field `_transactionContextPropagator` and a lock for thread safety. Introduced methods to get and set the transaction context propagator, and to manage the transaction context using the propagator. [[1]](diffhunk://#diff-dc150fbd3b7be797470374ee7e38c7ab8a31eb9b6b7a52345e05114c2d32a15eR25-R26) [[2]](diffhunk://#diff-dc150fbd3b7be797470374ee7e38c7ab8a31eb9b6b7a52345e05114c2d32a15eR222-R274) * [`src/OpenFeature/AsyncLocalTransactionContextPropagator.cs`](diffhunk://#diff-d9ca58e32d696079f875c51837dfc6ded087b06eb3aef3513f5ea15ebc22c700R1-R25): Implemented the `AsyncLocalTransactionContextPropagator` class that uses `AsyncLocal<T>` to store the transaction context. * [`src/OpenFeature/NoOpTransactionContextPropagator.cs`](diffhunk://#diff-09ab422ed267155042b791de4d1c88f1bd82cb68d5f541a92c6af4318ceacd6aR1-R15): Implemented a no-op version of the `ITransactionContextPropagator` interface. * [`src/OpenFeature/Model/ITransactionContextPropagator.cs`](diffhunk://#diff-614f5b3e42871f4057f04d5fd27bf56157315e1c822ff0c83403255a8bf163ecR1-R26): Defined the `ITransactionContextPropagator` interface responsible for persisting transaction contexts. #### API Enhancements: * [`src/OpenFeature/Api.cs`](diffhunk://#diff-dc150fbd3b7be797470374ee7e38c7ab8a31eb9b6b7a52345e05114c2d32a15eR291): Updated the `ShutdownAsync` method to reset the transaction context propagator. * [`src/OpenFeature/OpenFeatureClient.cs`](diffhunk://#diff-c23c8a3ea4538fbdcf6b1cf93ea3de456906e4d267fc4b2ba3f8b1cb186a7907R224): Modified the `EvaluateFlagAsync` method to merge the transaction context with the evaluation context. --------- Signed-off-by: André Silva <[email protected]> Signed-off-by: Todd Baert <[email protected]> Co-authored-by: Todd Baert <[email protected]>
1 parent 2ef9955 commit 1b5a0a9

9 files changed

+311
-19
lines changed

README.md

+35-12
Original file line numberDiff line numberDiff line change
@@ -68,18 +68,19 @@ public async Task Example()
6868

6969
## 🌟 Features
7070

71-
| Status | Features | Description |
72-
| ------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
73-
|| [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. |
74-
|| [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). |
75-
|| [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. |
76-
|| [Tracking](#tracking) | Associate user actions with feature flag evaluations. |
77-
|| [Logging](#logging) | Integrate with popular logging packages. |
78-
|| [Domains](#domains) | Logically bind clients with providers. |
79-
|| [Eventing](#eventing) | React to state changes in the provider or flag management system. |
80-
|| [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
81-
|| [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
82-
| 🔬 | [DependencyInjection](#DependencyInjection) | Integrate OpenFeature with .NET's dependency injection for streamlined provider setup. |
71+
| Status | Features | Description |
72+
| ------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
73+
|| [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. |
74+
|| [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). |
75+
|| [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. |
76+
|| [Tracking](#tracking) | Associate user actions with feature flag evaluations. |
77+
|| [Logging](#logging) | Integrate with popular logging packages. |
78+
|| [Domains](#domains) | Logically bind clients with providers. |
79+
|| [Eventing](#eventing) | React to state changes in the provider or flag management system. |
80+
|| [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
81+
|| [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). |
82+
|| [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
83+
| 🔬 | [DependencyInjection](#DependencyInjection) | Integrate OpenFeature with .NET's dependency injection for streamlined provider setup. |
8384

8485
> Implemented: ✅ | In-progress: ⚠️ | Not implemented yet: ❌ | Experimental: 🔬
8586
@@ -234,6 +235,28 @@ The OpenFeature API provides a close function to perform a cleanup of all regist
234235
await Api.Instance.ShutdownAsync();
235236
```
236237

238+
### Transaction Context Propagation
239+
240+
Transaction context is a container for transaction-specific evaluation context (e.g. user id, user agent, IP).
241+
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).
242+
By default, the `NoOpTransactionContextPropagator` is used, which doesn't store anything.
243+
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.
244+
245+
```csharp
246+
// registering the AsyncLocalTransactionContextPropagator
247+
Api.Instance.SetTransactionContextPropagator(new AsyncLocalTransactionContextPropagator());
248+
```
249+
Once you've registered a transaction context propagator, you can propagate the data into request-scoped transaction context.
250+
251+
```csharp
252+
// adding userId to transaction context
253+
EvaluationContext transactionContext = EvaluationContext.Builder()
254+
.Set("userId", userId)
255+
.Build();
256+
Api.Instance.SetTransactionContext(transactionContext);
257+
```
258+
Additionally, you can develop a custom transaction context propagator by implementing the `TransactionContextPropagator` interface and registering it as shown above.
259+
237260
## Extending
238261

239262
### Develop a provider

src/OpenFeature/Api.cs

+57-2
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ public sealed class Api : IEventBus
2222
private EventExecutor _eventExecutor = new EventExecutor();
2323
private ProviderRepository _repository = new ProviderRepository();
2424
private readonly ConcurrentStack<Hook> _hooks = new ConcurrentStack<Hook>();
25+
private ITransactionContextPropagator _transactionContextPropagator = new NoOpTransactionContextPropagator();
26+
private readonly object _transactionContextPropagatorLock = new();
2527

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

5255
/// <summary>
@@ -85,7 +88,6 @@ public FeatureProvider GetProvider()
8588
/// Gets the feature provider with given domain
8689
/// </summary>
8790
/// <param name="domain">An identifier which logically binds clients with providers</param>
88-
8991
/// <returns>A provider associated with the given domain, if domain is empty or doesn't
9092
/// have a corresponding provider the default provider will be returned</returns>
9193
public FeatureProvider GetProvider(string domain)
@@ -109,7 +111,6 @@ public FeatureProvider GetProvider(string domain)
109111
/// assigned to it the default provider will be returned
110112
/// </summary>
111113
/// <param name="domain">An identifier which logically binds clients with providers</param>
112-
113114
/// <returns>Metadata assigned to provider</returns>
114115
public Metadata? GetProviderMetadata(string domain) => this.GetProvider(domain).GetMetadata();
115116

@@ -218,6 +219,59 @@ public EvaluationContext GetContext()
218219
}
219220
}
220221

222+
/// <summary>
223+
/// Return the transaction context propagator.
224+
/// </summary>
225+
/// <returns><see cref="ITransactionContextPropagator"/>the registered transaction context propagator</returns>
226+
internal ITransactionContextPropagator GetTransactionContextPropagator()
227+
{
228+
return this._transactionContextPropagator;
229+
}
230+
231+
/// <summary>
232+
/// Sets the transaction context propagator.
233+
/// </summary>
234+
/// <param name="transactionContextPropagator">the transaction context propagator to be registered</param>
235+
/// <exception cref="ArgumentNullException">Transaction context propagator cannot be null</exception>
236+
public void SetTransactionContextPropagator(ITransactionContextPropagator transactionContextPropagator)
237+
{
238+
if (transactionContextPropagator == null)
239+
{
240+
throw new ArgumentNullException(nameof(transactionContextPropagator),
241+
"Transaction context propagator cannot be null");
242+
}
243+
244+
lock (this._transactionContextPropagatorLock)
245+
{
246+
this._transactionContextPropagator = transactionContextPropagator;
247+
}
248+
}
249+
250+
/// <summary>
251+
/// Returns the currently defined transaction context using the registered transaction context propagator.
252+
/// </summary>
253+
/// <returns><see cref="EvaluationContext"/>The current transaction context</returns>
254+
public EvaluationContext GetTransactionContext()
255+
{
256+
return this._transactionContextPropagator.GetTransactionContext();
257+
}
258+
259+
/// <summary>
260+
/// Sets the transaction context using the registered transaction context propagator.
261+
/// </summary>
262+
/// <param name="evaluationContext">The <see cref="EvaluationContext"/> to set</param>
263+
/// <exception cref="InvalidOperationException">Transaction context propagator is not set.</exception>
264+
/// <exception cref="ArgumentNullException">Evaluation context cannot be null</exception>
265+
public void SetTransactionContext(EvaluationContext evaluationContext)
266+
{
267+
if (evaluationContext == null)
268+
{
269+
throw new ArgumentNullException(nameof(evaluationContext), "Evaluation context cannot be null");
270+
}
271+
272+
this._transactionContextPropagator.SetTransactionContext(evaluationContext);
273+
}
274+
221275
/// <summary>
222276
/// <para>
223277
/// Shut down and reset the current status of OpenFeature API.
@@ -234,6 +288,7 @@ public async Task ShutdownAsync()
234288
{
235289
this._evaluationContext = EvaluationContext.Empty;
236290
this._hooks.Clear();
291+
this._transactionContextPropagator = new NoOpTransactionContextPropagator();
237292

238293
// TODO: make these lazy to avoid extra allocations on the common cleanup path?
239294
this._eventExecutor = new EventExecutor();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using System.Threading;
2+
using OpenFeature.Model;
3+
4+
namespace OpenFeature;
5+
6+
/// <summary>
7+
/// This is a task transaction context implementation of <see cref="ITransactionContextPropagator"/>
8+
/// It uses the <see cref="AsyncLocal{T}"/> to store the transaction context.
9+
/// </summary>
10+
public sealed class AsyncLocalTransactionContextPropagator : ITransactionContextPropagator
11+
{
12+
private readonly AsyncLocal<EvaluationContext> _transactionContext = new();
13+
14+
/// <inheritdoc />
15+
public EvaluationContext GetTransactionContext()
16+
{
17+
return this._transactionContext.Value ?? EvaluationContext.Empty;
18+
}
19+
20+
/// <inheritdoc />
21+
public void SetTransactionContext(EvaluationContext evaluationContext)
22+
{
23+
this._transactionContext.Value = evaluationContext;
24+
}
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
namespace OpenFeature.Model;
2+
3+
/// <summary>
4+
/// <see cref="ITransactionContextPropagator"/> is responsible for persisting a transactional context
5+
/// for the duration of a single transaction.
6+
/// Examples of potential transaction specific context include: a user id, user agent, IP.
7+
/// Transaction context is merged with evaluation context prior to flag evaluation.
8+
/// </summary>
9+
/// <remarks>
10+
/// The precedence of merging context can be seen in
11+
/// <a href="https://openfeature.dev/specification/sections/evaluation-context#requirement-323">the specification</a>.
12+
/// </remarks>
13+
public interface ITransactionContextPropagator
14+
{
15+
/// <summary>
16+
/// Returns the currently defined transaction context using the registered transaction context propagator.
17+
/// </summary>
18+
/// <returns><see cref="EvaluationContext"/>The current transaction context</returns>
19+
EvaluationContext GetTransactionContext();
20+
21+
/// <summary>
22+
/// Sets the transaction context.
23+
/// </summary>
24+
/// <param name="evaluationContext">The transaction context to be set</param>
25+
void SetTransactionContext(EvaluationContext evaluationContext);
26+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
using OpenFeature.Model;
2+
3+
namespace OpenFeature;
4+
5+
internal class NoOpTransactionContextPropagator : ITransactionContextPropagator
6+
{
7+
public EvaluationContext GetTransactionContext()
8+
{
9+
return EvaluationContext.Empty;
10+
}
11+
12+
public void SetTransactionContext(EvaluationContext evaluationContext)
13+
{
14+
}
15+
}

src/OpenFeature/OpenFeatureClient.cs

+5-5
Original file line numberDiff line numberDiff line change
@@ -215,12 +215,12 @@ private async Task<FlagEvaluationDetails<T>> EvaluateFlagAsync<T>(
215215
// New up an evaluation context if one was not provided.
216216
context ??= EvaluationContext.Empty;
217217

218-
// merge api, client, and invocation context.
219-
var evaluationContext = Api.Instance.GetContext();
218+
// merge api, client, transaction and invocation context
220219
var evaluationContextBuilder = EvaluationContext.Builder();
221-
evaluationContextBuilder.Merge(evaluationContext);
222-
evaluationContextBuilder.Merge(this.GetContext());
223-
evaluationContextBuilder.Merge(context);
220+
evaluationContextBuilder.Merge(Api.Instance.GetContext()); // API context
221+
evaluationContextBuilder.Merge(this.GetContext()); // Client context
222+
evaluationContextBuilder.Merge(Api.Instance.GetTransactionContext()); // Transaction context
223+
evaluationContextBuilder.Merge(context); // Invocation context
224224

225225
var allHooks = new List<Hook>()
226226
.Concat(Api.Instance.GetHooks())
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
using OpenFeature.Model;
2+
using Xunit;
3+
4+
namespace OpenFeature.Tests;
5+
6+
public class AsyncLocalTransactionContextPropagatorTests
7+
{
8+
[Fact]
9+
public void GetTransactionContext_ReturnsEmpty_WhenNoContextIsSet()
10+
{
11+
// Arrange
12+
var propagator = new AsyncLocalTransactionContextPropagator();
13+
14+
// Act
15+
var context = propagator.GetTransactionContext();
16+
17+
// Assert
18+
Assert.Equal(EvaluationContext.Empty, context);
19+
}
20+
21+
[Fact]
22+
public void SetTransactionContext_SetsAndGetsContextCorrectly()
23+
{
24+
// Arrange
25+
var propagator = new AsyncLocalTransactionContextPropagator();
26+
var evaluationContext = EvaluationContext.Builder()
27+
.Set("initial", "yes")
28+
.Build();
29+
30+
// Act
31+
propagator.SetTransactionContext(evaluationContext);
32+
var context = propagator.GetTransactionContext();
33+
34+
// Assert
35+
Assert.Equal(evaluationContext, context);
36+
Assert.Equal(evaluationContext.GetValue("initial"), context.GetValue("initial"));
37+
}
38+
39+
[Fact]
40+
public void SetTransactionContext_OverridesPreviousContext()
41+
{
42+
// Arrange
43+
var propagator = new AsyncLocalTransactionContextPropagator();
44+
45+
var initialContext = EvaluationContext.Builder()
46+
.Set("initial", "yes")
47+
.Build();
48+
var newContext = EvaluationContext.Empty;
49+
50+
// Act
51+
propagator.SetTransactionContext(initialContext);
52+
propagator.SetTransactionContext(newContext);
53+
var context = propagator.GetTransactionContext();
54+
55+
// Assert
56+
Assert.Equal(newContext, context);
57+
}
58+
}

test/OpenFeature.Tests/OpenFeatureHookTests.cs

+17
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,9 @@ public async Task Evaluation_Context_Must_Be_Merged_In_Correct_Order()
166166
var propInvocation = "4.3.4invocation";
167167
var propInvocationToOverwrite = "4.3.4invocationToOverwrite";
168168

169+
var propTransaction = "4.3.4transaction";
170+
var propTransactionToOverwrite = "4.3.4transactionToOverwrite";
171+
169172
var propHook = "4.3.4hook";
170173

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

186+
var transactionContext = new EvaluationContextBuilder()
187+
.Set(propTransaction, true)
188+
.Set(propInvocationToOverwrite, true)
189+
.Set(propTransactionToOverwrite, false)
190+
.Build();
191+
183192
var invocationContext = new EvaluationContextBuilder()
184193
.Set(propInvocation, true)
185194
.Set(propClientToOverwrite, true)
195+
.Set(propTransactionToOverwrite, true)
186196
.Set(propInvocationToOverwrite, false)
187197
.Build();
188198

199+
189200
var hookContext = new EvaluationContextBuilder()
190201
.Set(propHook, true)
191202
.Set(propInvocationToOverwrite, true)
192203
.Build();
193204

205+
var transactionContextPropagator = new AsyncLocalTransactionContextPropagator();
206+
transactionContextPropagator.SetTransactionContext(transactionContext);
207+
Api.Instance.SetTransactionContextPropagator(transactionContextPropagator);
208+
194209
var provider = Substitute.For<FeatureProvider>();
195210

196211
provider.GetMetadata().Returns(new Metadata(null));
@@ -212,7 +227,9 @@ public async Task Evaluation_Context_Must_Be_Merged_In_Correct_Order()
212227
_ = provider.Received(1).ResolveBooleanValueAsync(Arg.Any<string>(), Arg.Any<bool>(), Arg.Is<EvaluationContext>(y =>
213228
(y.GetValue(propGlobal).AsBoolean ?? false)
214229
&& (y.GetValue(propClient).AsBoolean ?? false)
230+
&& (y.GetValue(propTransaction).AsBoolean ?? false)
215231
&& (y.GetValue(propGlobalToOverwrite).AsBoolean ?? false)
232+
&& (y.GetValue(propTransactionToOverwrite).AsBoolean ?? false)
216233
&& (y.GetValue(propInvocation).AsBoolean ?? false)
217234
&& (y.GetValue(propClientToOverwrite).AsBoolean ?? false)
218235
&& (y.GetValue(propHook).AsBoolean ?? false)

0 commit comments

Comments
 (0)