Skip to content

Commit 56c5a2f

Browse files
committed
feat: implement transaction context
Signed-off-by: Lukas Reining <[email protected]>
1 parent e72e329 commit 56c5a2f

File tree

9 files changed

+353
-11
lines changed

9 files changed

+353
-11
lines changed

README.md

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -99,16 +99,17 @@ print("Value: " + str(flag_value))
9999

100100
## 🌟 Features
101101

102-
| Status | Features | Description |
103-
| ------ | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
104-
|| [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. |
105-
|| [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). |
106-
|| [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. |
107-
|| [Logging](#logging) | Integrate with popular logging packages. |
108-
|| [Domains](#domains) | Logically bind clients with providers. |
109-
|| [Eventing](#eventing) | React to state changes in the provider or flag management system. |
110-
|| [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
111-
|| [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
102+
| Status | Features | Description |
103+
|--------|---------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------|
104+
|| [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. |
105+
|| [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). |
106+
|| [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. |
107+
|| [Logging](#logging) | Integrate with popular logging packages. |
108+
|| [Domains](#domains) | Logically bind clients with providers. |
109+
|| [Eventing](#eventing) | React to state changes in the provider or flag management system. |
110+
|| [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
111+
|| [Transaction Context Propagation](#transaction-context-propagation) | Set a specific [evaluation context](/docs/reference/concepts/evaluation-context) for a transaction (e.g. an HTTP request or a thread) |
112+
|| [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
112113

113114
<sub>Implemented: ✅ | In-progress: ⚠️ | Not implemented yet: ❌</sub>
114115

@@ -235,6 +236,86 @@ def on_provider_ready(event_details: EventDetails):
235236
client.add_handler(ProviderEvent.PROVIDER_READY, on_provider_ready)
236237
```
237238

239+
### Transaction Context Propagation
240+
241+
Transaction context is a container for transaction-specific evaluation context (e.g. user id, user agent, IP).
242+
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).
243+
244+
You can implement a different transaction context propagator by implementing the `TransactionContextPropagator` class exported by the OpenFeature SDK.
245+
In most cases you can use `ContextVarsTransactionContextPropagator` as it works for `threads` and `asyncio` using [Context Variables](https://peps.python.org/pep-0567/).
246+
247+
The following example shows a **multithreaded** Flask application using transaction context propagation to propagate the request ip and user id into request scoped transaction context.
248+
249+
```python
250+
from flask import Flask, request
251+
from openfeature import api
252+
from openfeature.evaluation_context import EvaluationContext
253+
from openfeature.transaction_context import ContextVarsTransactionContextPropagator
254+
255+
# Initialize the Flask app
256+
app = Flask(__name__)
257+
258+
# Set the transaction context propagator
259+
api.set_transaction_context_propagator(ContextVarsTransactionContextPropagator())
260+
261+
# Middleware to set the transaction context
262+
# You can call api.set_transaction_context anywhere you have information,
263+
# you want to have available in the code-paths below the current one.
264+
@app.before_request
265+
def set_request_transaction_context():
266+
ip = request.headers.get("X-Forwarded-For", request.remote_addr)
267+
user_id = request.headers.get("User-Id") # Assuming we're getting the user ID from a header
268+
evaluation_context = EvaluationContext(targeting_key=user_id, attributes={"ipAddress": ip})
269+
api.set_transaction_context(evaluation_context)
270+
271+
def create_response() -> str:
272+
# This method can be anywhere in our code.
273+
# The feature flag evaluation will automatically contain the transaction context merged with other context
274+
new_response = api.get_client().get_string_value("response-message", "Hello User!")
275+
return f"Message from server: {new_response}"
276+
277+
# Example route where we use the transaction context
278+
@app.route('/greeting')
279+
def some_endpoint():
280+
return create_response()
281+
```
282+
283+
This also works for asyncio based implementations e.g. FastApi as seen in the following example:
284+
285+
```python
286+
from fastapi import FastAPI, Request
287+
from openfeature import api
288+
from openfeature.evaluation_context import EvaluationContext
289+
from openfeature.transaction_context import ContextVarsTransactionContextPropagator
290+
291+
# Initialize the FastAPI app
292+
app = FastAPI()
293+
294+
# Set the transaction context propagator
295+
api.set_transaction_context_propagator(ContextVarsTransactionContextPropagator())
296+
297+
# Middleware to set the transaction context
298+
@app.middleware("http")
299+
async def set_request_transaction_context(request: Request, call_next):
300+
ip = request.headers.get("X-Forwarded-For", request.client.host)
301+
user_id = request.headers.get("User-Id") # Assuming we're getting the user ID from a header
302+
evaluation_context = EvaluationContext(targeting_key=user_id, attributes={"ipAddress": ip})
303+
api.set_transaction_context(evaluation_context)
304+
response = await call_next(request)
305+
return response
306+
307+
def create_response() -> str:
308+
# This method can be located anywhere in our code.
309+
# The feature flag evaluation will automatically include the transaction context merged with other context.
310+
new_response = api.get_client().get_string_value("response-message", "Hello User!")
311+
return f"Message from server: {new_response}"
312+
313+
# Example route where we use the transaction context
314+
@app.get('/greeting')
315+
async def some_endpoint():
316+
return create_response()
317+
```
318+
238319
### Shutdown
239320

240321
The OpenFeature API provides a shutdown function to perform a cleanup of all registered providers. This should only be called when your application is in the process of shutting down.

openfeature/api.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
from openfeature.provider import FeatureProvider
1313
from openfeature.provider._registry import provider_registry
1414
from openfeature.provider.metadata import Metadata
15+
from openfeature.transaction_context import (
16+
NoOpTransactionContextPropagator,
17+
TransactionContextPropagator,
18+
)
1519

1620
__all__ = [
1721
"get_client",
@@ -20,6 +24,9 @@
2024
"get_provider_metadata",
2125
"get_evaluation_context",
2226
"set_evaluation_context",
27+
"set_transaction_context_propagator",
28+
"get_transaction_context",
29+
"set_transaction_context",
2330
"add_hooks",
2431
"clear_hooks",
2532
"get_hooks",
@@ -29,6 +36,7 @@
2936
]
3037

3138
_evaluation_context = EvaluationContext()
39+
_evaluation_transaction_context_propagator = NoOpTransactionContextPropagator()
3240

3341
_hooks: typing.List[Hook] = []
3442

@@ -68,6 +76,24 @@ def set_evaluation_context(evaluation_context: EvaluationContext) -> None:
6876
_evaluation_context = evaluation_context
6977

7078

79+
def set_transaction_context_propagator(
80+
transaction_context_propagator: TransactionContextPropagator,
81+
) -> None:
82+
global _evaluation_transaction_context_propagator
83+
_evaluation_transaction_context_propagator = transaction_context_propagator
84+
85+
86+
def get_transaction_context() -> EvaluationContext:
87+
return _evaluation_transaction_context_propagator.get_transaction_context()
88+
89+
90+
def set_transaction_context(evaluation_context: EvaluationContext) -> None:
91+
global _evaluation_transaction_context_propagator
92+
_evaluation_transaction_context_propagator.set_transaction_context(
93+
evaluation_context
94+
)
95+
96+
7197
def add_hooks(hooks: typing.List[Hook]) -> None:
7298
global _hooks
7399
_hooks = _hooks + hooks

openfeature/client.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,9 +335,10 @@ def evaluate_flag_details( # noqa: PLR0915
335335
)
336336
invocation_context = invocation_context.merge(ctx2=evaluation_context)
337337

338-
# Requirement 3.2.2 merge: API.context->client.context->invocation.context
338+
# Requirement 3.2.2 merge: API.context->transaction.context->client.context->invocation.context
339339
merged_context = (
340340
api.get_evaluation_context()
341+
.merge(api.get_transaction_context())
341342
.merge(self.context)
342343
.merge(invocation_context)
343344
)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from openfeature.transaction_context.context_var_transaction_context_propagator import (
2+
ContextVarsTransactionContextPropagator,
3+
)
4+
from openfeature.transaction_context.no_op_transaction_context_propagator import (
5+
NoOpTransactionContextPropagator,
6+
)
7+
from openfeature.transaction_context.transaction_context_propagator import (
8+
TransactionContextPropagator,
9+
)
10+
11+
__all__ = [
12+
"TransactionContextPropagator",
13+
"NoOpTransactionContextPropagator",
14+
"ContextVarsTransactionContextPropagator",
15+
]
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from contextvars import ContextVar
2+
3+
from openfeature.evaluation_context import EvaluationContext
4+
from openfeature.transaction_context.transaction_context_propagator import (
5+
TransactionContextPropagator,
6+
)
7+
8+
_transaction_context_var: ContextVar[EvaluationContext] = ContextVar(
9+
"transaction_context", default=EvaluationContext()
10+
)
11+
12+
13+
class ContextVarsTransactionContextPropagator(TransactionContextPropagator):
14+
def get_transaction_context(self) -> EvaluationContext:
15+
return _transaction_context_var.get()
16+
17+
def set_transaction_context(self, transaction_context: EvaluationContext) -> None:
18+
_transaction_context_var.set(transaction_context)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from openfeature.evaluation_context import EvaluationContext
2+
from openfeature.transaction_context.transaction_context_propagator import (
3+
TransactionContextPropagator,
4+
)
5+
6+
7+
class NoOpTransactionContextPropagator(TransactionContextPropagator):
8+
def get_transaction_context(self) -> EvaluationContext:
9+
return EvaluationContext()
10+
11+
def set_transaction_context(self, transaction_context: EvaluationContext) -> None:
12+
pass
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from abc import ABC, abstractmethod
2+
from typing import TypeVar
3+
4+
from openfeature.evaluation_context import EvaluationContext
5+
6+
T = TypeVar("T", bound="TransactionContextPropagator")
7+
8+
9+
class TransactionContextPropagator(ABC):
10+
@abstractmethod
11+
def get_transaction_context(self) -> EvaluationContext:
12+
pass
13+
14+
@abstractmethod
15+
def set_transaction_context(self, transaction_context: EvaluationContext) -> None:
16+
pass

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ dependencies = [
3434
"behave",
3535
"coverage[toml]>=6.5",
3636
"pytest",
37+
"pytest-asyncio"
3738
]
3839

3940
[tool.hatch.envs.default.scripts]

0 commit comments

Comments
 (0)