Skip to content

Commit 9b97130

Browse files
feat: implement transaction context (#389)
* feat: implement transaction context Signed-off-by: Lukas Reining <[email protected]> * fix: lint issues Signed-off-by: Lukas Reining <[email protected]> * feat: add tests for context merging Signed-off-by: Lukas Reining <[email protected]> * feat: fix pre-commit checks Signed-off-by: Lukas Reining <[email protected]> * feat: use elipsis instead of pass Signed-off-by: Lukas Reining <[email protected]> * Update openfeature/transaction_context/no_op_transaction_context_propagator.py Co-authored-by: Anton Grübel <[email protected]> Signed-off-by: Lukas Reining <[email protected]> * feat: pr feedback Signed-off-by: Lukas Reining <[email protected]> --------- Signed-off-by: Lukas Reining <[email protected]> Co-authored-by: Anton Grübel <[email protected]>
1 parent f024a6f commit 9b97130

10 files changed

+396
-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: 28 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 TransactionContextPropagator
16+
from openfeature.transaction_context.no_op_transaction_context_propagator import (
17+
NoOpTransactionContextPropagator,
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,9 @@
2936
]
3037

3138
_evaluation_context = EvaluationContext()
39+
_evaluation_transaction_context_propagator: TransactionContextPropagator = (
40+
NoOpTransactionContextPropagator()
41+
)
3242

3343
_hooks: typing.List[Hook] = []
3444

@@ -68,6 +78,24 @@ def set_evaluation_context(evaluation_context: EvaluationContext) -> None:
6878
_evaluation_context = evaluation_context
6979

7080

81+
def set_transaction_context_propagator(
82+
transaction_context_propagator: TransactionContextPropagator,
83+
) -> None:
84+
global _evaluation_transaction_context_propagator
85+
_evaluation_transaction_context_propagator = transaction_context_propagator
86+
87+
88+
def get_transaction_context() -> EvaluationContext:
89+
return _evaluation_transaction_context_propagator.get_transaction_context()
90+
91+
92+
def set_transaction_context(evaluation_context: EvaluationContext) -> None:
93+
global _evaluation_transaction_context_propagator
94+
_evaluation_transaction_context_propagator.set_transaction_context(
95+
evaluation_context
96+
)
97+
98+
7199
def add_hooks(hooks: typing.List[Hook]) -> None:
72100
global _hooks
73101
_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: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from openfeature.transaction_context.context_var_transaction_context_propagator import (
2+
ContextVarsTransactionContextPropagator,
3+
)
4+
from openfeature.transaction_context.transaction_context_propagator import (
5+
TransactionContextPropagator,
6+
)
7+
8+
__all__ = [
9+
"TransactionContextPropagator",
10+
"ContextVarsTransactionContextPropagator",
11+
]
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+
9+
class ContextVarsTransactionContextPropagator(TransactionContextPropagator):
10+
_transaction_context_var: ContextVar[EvaluationContext] = ContextVar(
11+
"transaction_context", default=EvaluationContext()
12+
)
13+
14+
def get_transaction_context(self) -> EvaluationContext:
15+
return self._transaction_context_var.get()
16+
17+
def set_transaction_context(self, transaction_context: EvaluationContext) -> None:
18+
self._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: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import typing
2+
3+
from openfeature.evaluation_context import EvaluationContext
4+
5+
6+
class TransactionContextPropagator(typing.Protocol):
7+
def get_transaction_context(self) -> EvaluationContext: ...
8+
9+
def set_transaction_context(
10+
self, transaction_context: EvaluationContext
11+
) -> None: ...

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]

tests/test_client.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,18 @@
55

66
import pytest
77

8+
from openfeature import api
89
from openfeature.api import add_hooks, clear_hooks, get_client, set_provider
910
from openfeature.client import OpenFeatureClient
11+
from openfeature.evaluation_context import EvaluationContext
1012
from openfeature.event import EventDetails, ProviderEvent, ProviderEventDetails
1113
from openfeature.exception import ErrorCode, OpenFeatureError
1214
from openfeature.flag_evaluation import FlagResolutionDetails, Reason
1315
from openfeature.hook import Hook
1416
from openfeature.provider import FeatureProvider, ProviderStatus
1517
from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider
1618
from openfeature.provider.no_op_provider import NoOpProvider
19+
from openfeature.transaction_context import ContextVarsTransactionContextPropagator
1720

1821

1922
@pytest.mark.parametrize(
@@ -384,3 +387,47 @@ def emit_events_task():
384387
f2 = executor.submit(emit_events_task)
385388
f1.result()
386389
f2.result()
390+
391+
392+
def test_client_should_merge_contexts():
393+
api.clear_hooks()
394+
api.set_transaction_context_propagator(ContextVarsTransactionContextPropagator())
395+
396+
provider = NoOpProvider()
397+
provider.resolve_boolean_details = MagicMock(wraps=provider.resolve_boolean_details)
398+
api.set_provider(provider)
399+
400+
# Global evaluation context
401+
global_context = EvaluationContext(
402+
targeting_key="global", attributes={"global_attr": "global_value"}
403+
)
404+
api.set_evaluation_context(global_context)
405+
406+
# Transaction context
407+
transaction_context = EvaluationContext(
408+
targeting_key="transaction",
409+
attributes={"transaction_attr": "transaction_value"},
410+
)
411+
api.set_transaction_context(transaction_context)
412+
413+
# Client-specific context
414+
client_context = EvaluationContext(
415+
targeting_key="client", attributes={"client_attr": "client_value"}
416+
)
417+
client = OpenFeatureClient(domain=None, version=None, context=client_context)
418+
419+
# Invocation-specific context
420+
invocation_context = EvaluationContext(
421+
targeting_key="invocation", attributes={"invocation_attr": "invocation_value"}
422+
)
423+
client.get_boolean_details("flag", False, invocation_context)
424+
425+
# Retrieve the call arguments
426+
args, kwargs = provider.resolve_boolean_details.call_args
427+
flag_key, default_value, context = args
428+
429+
assert context.targeting_key == "invocation" # Last one in the merge chain
430+
assert context.attributes["global_attr"] == "global_value"
431+
assert context.attributes["transaction_attr"] == "transaction_value"
432+
assert context.attributes["client_attr"] == "client_value"
433+
assert context.attributes["invocation_attr"] == "invocation_value"

0 commit comments

Comments
 (0)