Skip to content

Commit b1abef1

Browse files
feat: context propagation (#837)
<!-- Please use this template for your pull request. --> <!-- Please use the sections that you need and delete other sections --> ## This PR <!-- add the description of the PR here --> - removes experimental warning from context propagation js-docs - improves typing of `setTransactionContext` - adds `AsyncLocalStorageTransactionContextProvider` to server SDK - @beeme1mr @toddbaert I am not 100% sure on this one. To me it makes much sense to add this to the server SDK as it uses the Node default way `async_hooks`/`async_local_storage` which is part of Node since Node 16.x. I expect almost every project using the feature, to build exactly this so I wanted to include it in the SDK. As we are using Node types anyways I do not see a problem here, but still we could leave this out as it couples the implementation closer to Node. Before merging I will have to change the README. --------- Signed-off-by: Lukas Reining <[email protected]>
1 parent 8101ff1 commit b1abef1

File tree

10 files changed

+174
-60
lines changed

10 files changed

+174
-60
lines changed

packages/nest/src/evaluation-context-propagator.ts

Lines changed: 0 additions & 13 deletions
This file was deleted.

packages/nest/src/open-feature.module.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ import {
1616
ServerProviderEvents,
1717
EventHandler,
1818
Logger,
19+
AsyncLocalStorageTransactionContextPropagator,
1920
} from '@openfeature/server-sdk';
2021
import { ContextFactory, ContextFactoryToken } from './context-factory';
2122
import { APP_INTERCEPTOR } from '@nestjs/core';
22-
import { AsyncLocalStorageTransactionContext } from './evaluation-context-propagator';
2323
import { EvaluationContextInterceptor } from './evaluation-context-interceptor';
2424
import { ShutdownService } from './shutdown.service';
2525

@@ -29,7 +29,7 @@ import { ShutdownService } from './shutdown.service';
2929
@Module({})
3030
export class OpenFeatureModule {
3131
static forRoot({ useGlobalInterceptor = true, ...options }: OpenFeatureModuleOptions): DynamicModule {
32-
OpenFeature.setTransactionContextPropagator(new AsyncLocalStorageTransactionContext());
32+
OpenFeature.setTransactionContextPropagator(new AsyncLocalStorageTransactionContextPropagator());
3333

3434
if (options.logger) {
3535
OpenFeature.setLogger(options.logger);
@@ -130,7 +130,7 @@ export interface OpenFeatureModuleOptions {
130130
* The {@link ContextFactory} for creating an {@link EvaluationContext} from Nest {@link ExecutionContext} information.
131131
* This could be header values of a request or something similar.
132132
* The context is automatically used for all feature flag evaluations during this request.
133-
* @see {@link AsyncLocalStorageTransactionContext}
133+
* @see {@link AsyncLocalStorageTransactionContextPropagator}
134134
*/
135135
contextFactory?: ContextFactory;
136136
/**

packages/server/README.md

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -86,16 +86,17 @@ See [here](https://open-feature.github.io/js-sdk/modules/_openfeature_server_sdk
8686

8787
## 🌟 Features
8888

89-
| Status | Features | Description |
90-
| ------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
91-
|| [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. |
92-
|| [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). |
93-
|| [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. |
94-
|| [Logging](#logging) | Integrate with popular logging packages. |
95-
|| [Domains](#domains) | Logically bind clients with providers. |
96-
|| [Eventing](#eventing) | React to state changes in the provider or flag management system. |
97-
|| [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
98-
|| [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
89+
| Status | Features | Description |
90+
|--------|---------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|
91+
|| [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. |
92+
|| [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). |
93+
|| [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. |
94+
|| [Logging](#logging) | Integrate with popular logging packages. |
95+
|| [Domains](#domains) | Logically bind clients with providers. |
96+
|| [Eventing](#eventing) | React to state changes in the provider or flag management system. |
97+
|| [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
98+
|| [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) | |
99+
|| [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
99100

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

@@ -113,7 +114,7 @@ To register a provider and ensure it is ready before further actions are taken,
113114

114115
```ts
115116
await OpenFeature.setProviderAndWait(new MyProvider());
116-
```
117+
```
117118

118119
#### Synchronous
119120

@@ -186,7 +187,7 @@ import type { Logger } from "@openfeature/server-sdk";
186187
// The logger can be anything that conforms with the Logger interface
187188
const logger: Logger = console;
188189

189-
// Sets a global logger
190+
// Sets a global logger
190191
OpenFeature.setLogger(logger);
191192

192193
// Sets a client logger
@@ -251,6 +252,32 @@ client.addHandler(ProviderEvents.Error, (eventDetails) => {
251252
});
252253
```
253254

255+
### Transaction Context Propagation
256+
257+
Transaction context is a container for transaction-specific evaluation context (e.g. user id, user agent, IP).
258+
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).
259+
260+
The following example shows an Express middleware using transaction context propagation to propagate the request ip and user id into request scoped transaction context.
261+
262+
```ts
263+
import express, { Request, Response, NextFunction } from "express";
264+
import { OpenFeature, AsyncLocalStorageTransactionContextPropagator } from '@openfeature/server-sdk';
265+
266+
OpenFeature.setTransactionContextPropagator(new AsyncLocalStorageTransactionContextPropagator())
267+
268+
/**
269+
* This example is based on an express middleware.
270+
*/
271+
const app = express();
272+
app.use((req: Request, res: Response, next: NextFunction) => {
273+
const ip = res.headers.get("X-Forwarded-For")
274+
OpenFeature.setTransactionContext({ targetingKey: req.user.id, ipAddress: ip }, () => {
275+
// The transaction context is used in any flag evaluation throughout the whole call chain of next
276+
next();
277+
});
278+
})
279+
```
280+
254281
### Shutdown
255282

256283
The OpenFeature API provides a close function to perform a cleanup of all registered providers.
@@ -305,7 +332,7 @@ class MyProvider implements Provider {
305332
}
306333

307334
// implement with "new OpenFeatureEventEmitter()", and use "emit()" to emit events
308-
events?: ProviderEventEmitter<AnyProviderEvent> | undefined;
335+
events?: ProviderEventEmitter<AnyProviderEvent> | undefined;
309336

310337
initialize?(context?: EvaluationContext | undefined): Promise<void> {
311338
// code to initialize your provider

packages/server/src/open-feature.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,10 @@ export class OpenFeatureAPI
154154
return this;
155155
}
156156

157-
setTransactionContext<R>(
157+
setTransactionContext<TArgs extends unknown[], R>(
158158
transactionContext: TransactionContext,
159-
callback: (...args: unknown[]) => R,
160-
...args: unknown[]
159+
callback: (...args: TArgs) => R,
160+
...args: TArgs
161161
): void {
162162
this._transactionContextPropagator.setTransactionContext(transactionContext, callback, ...args);
163163
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { EvaluationContext } from '@openfeature/core';
2+
import { TransactionContext, TransactionContextPropagator } from './transaction-context';
3+
import { AsyncLocalStorage } from 'async_hooks';
4+
5+
export class AsyncLocalStorageTransactionContextPropagator implements TransactionContextPropagator {
6+
private asyncLocalStorage = new AsyncLocalStorage<EvaluationContext>();
7+
8+
getTransactionContext(): EvaluationContext {
9+
return this.asyncLocalStorage.getStore() ?? {};
10+
}
11+
12+
setTransactionContext<TArgs extends unknown[], R>(
13+
transactionContext: TransactionContext,
14+
callback: (...args: TArgs) => R,
15+
...args: TArgs
16+
): void {
17+
this.asyncLocalStorage.run(transactionContext, callback, ...args);
18+
}
19+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './transaction-context';
22
export * from './no-op-transaction-context-propagator';
3+
export * from './async-local-storage-transaction-context-propagator';

packages/server/src/transaction-context/no-op-transaction-context-propagator.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import { EvaluationContext } from '@openfeature/core';
2-
import { TransactionContextPropagator } from './transaction-context';
2+
import { TransactionContext, TransactionContextPropagator } from './transaction-context';
33

44
class NoopTransactionContextPropagator implements TransactionContextPropagator {
55
getTransactionContext(): EvaluationContext {
66
return {};
77
}
88

9-
setTransactionContext(_: EvaluationContext, callback: () => void): void {
10-
callback();
9+
setTransactionContext<TArgs extends unknown[], R>(
10+
_: TransactionContext,
11+
callback: (...args: TArgs) => R,
12+
...args: TArgs
13+
): void {
14+
callback(...args);
1115
}
1216
}
1317

packages/server/src/transaction-context/transaction-context.ts

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,9 @@ export type TransactionContext = EvaluationContext;
99

1010
export interface ManageTransactionContextPropagator<T> extends TransactionContextPropagator {
1111
/**
12-
* EXPERIMENTAL: Transaction context propagation is experimental and subject to change.
13-
* The OpenFeature Enhancement Proposal regarding transaction context can be found [here](https://github.com/open-feature/ofep/pull/32).
14-
*
1512
* Sets a transaction context propagator on this receiver. The transaction context
1613
* propagator is responsible for persisting context for the duration of a single
1714
* transaction.
18-
* @experimental
1915
* @template T The type of the receiver
2016
* @param {TransactionContextPropagator} transactionContextPropagator The context propagator to be used
2117
* @returns {T} The receiver (this object)
@@ -25,30 +21,43 @@ export interface ManageTransactionContextPropagator<T> extends TransactionContex
2521

2622
export interface TransactionContextPropagator {
2723
/**
28-
* EXPERIMENTAL: Transaction context propagation is experimental and subject to change.
29-
* The OpenFeature Enhancement Proposal regarding transaction context can be found [here](https://github.com/open-feature/ofep/pull/32).
30-
*
3124
* Returns the currently defined transaction context using the registered transaction
3225
* context propagator.
33-
* @experimental
3426
* @returns {TransactionContext} The current transaction context
3527
*/
3628
getTransactionContext(): TransactionContext;
3729

3830
/**
39-
* EXPERIMENTAL: Transaction context propagation is experimental and subject to change.
40-
* The OpenFeature Enhancement Proposal regarding transaction context can be found [here](https://github.com/open-feature/ofep/pull/32).
41-
*
4231
* Sets the transaction context using the registered transaction context propagator.
43-
* @experimental
32+
* Runs the {@link callback} function, in which the {@link transactionContext} will be available by calling
33+
* {@link this#getTransactionContext}.
34+
*
35+
* The {@link TransactionContextPropagator} must persist the {@link transactionContext} and make it available
36+
* to {@link callback} via {@link this#getTransactionContext}.
37+
*
38+
* The precedence of merging context can be seen in {@link https://openfeature.dev/specification/sections/evaluation-context#requirement-323 the specification}.
39+
*
40+
* Example:
41+
*
42+
* ```js
43+
* app.use((req: Request, res: Response, next: NextFunction) => {
44+
* const ip = res.headers.get("X-Forwarded-For")
45+
* OpenFeature.setTransactionContext({ targetingKey: req.user.id, ipAddress: ip }, () => {
46+
* // The transaction context is used in any flag evaluation throughout the whole call chain of next
47+
* next();
48+
* });
49+
* })
50+
*
51+
* ```
52+
* @template TArgs The optional args passed to the callback function
4453
* @template R The return value of the callback
4554
* @param {TransactionContext} transactionContext The transaction specific context
46-
* @param {(...args: unknown[]) => R} callback Callback function used to set the transaction context on the stack
55+
* @param {(...args: unknown[]) => R} callback Callback function to run
4756
* @param {...unknown[]} args Optional arguments that are passed to the callback function
4857
*/
49-
setTransactionContext<R>(
58+
setTransactionContext<TArgs extends unknown[], R>(
5059
transactionContext: TransactionContext,
51-
callback: (...args: unknown[]) => R,
52-
...args: unknown[]
60+
callback: (...args: TArgs) => R,
61+
...args: TArgs
5362
): void;
5463
}

packages/server/test/client.spec.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ describe('OpenFeatureClient', () => {
210210
const defaultStringValue = 'other';
211211
const value: MyRestrictedString = await client.getStringValue<MyRestrictedString>(
212212
stringFlag,
213-
defaultStringValue
213+
defaultStringValue,
214214
);
215215

216216
expect(value).toEqual(STRING_VALUE);
@@ -238,7 +238,7 @@ describe('OpenFeatureClient', () => {
238238
const defaultNumberValue = 4096;
239239
const value: MyRestrictedNumber = await client.getNumberValue<MyRestrictedNumber>(
240240
numberFlag,
241-
defaultNumberValue
241+
defaultNumberValue,
242242
);
243243

244244
expect(value).toEqual(NUMBER_VALUE);
@@ -541,7 +541,7 @@ describe('OpenFeatureClient', () => {
541541
flagKey,
542542
defaultValue,
543543
expect.objectContaining({ transformed: false }),
544-
{}
544+
{},
545545
);
546546
});
547547
});
@@ -654,7 +654,7 @@ describe('OpenFeatureClient', () => {
654654
expect.objectContaining({
655655
targetingKey: TARGETING_KEY,
656656
}),
657-
expect.anything()
657+
expect.anything(),
658658
);
659659
});
660660
});
@@ -680,7 +680,7 @@ describe('OpenFeatureClient', () => {
680680
expect.objectContaining({
681681
...context,
682682
}),
683-
expect.anything()
683+
expect.anything(),
684684
);
685685
});
686686
});
@@ -724,9 +724,13 @@ describe('OpenFeatureClient', () => {
724724
return this.context;
725725
}
726726

727-
setTransactionContext(transactionContext: EvaluationContext, callback: () => void): void {
727+
setTransactionContext<TArgs extends unknown[], R>(
728+
transactionContext: TransactionContext,
729+
callback: (...args: TArgs) => R,
730+
...args: TArgs
731+
): void {
728732
this.context = transactionContext;
729-
callback();
733+
callback(...args);
730734
}
731735
}
732736

@@ -750,7 +754,7 @@ describe('OpenFeatureClient', () => {
750754
...invocationContext,
751755
...beforeHookContext,
752756
}),
753-
expect.anything()
757+
expect.anything(),
754758
);
755759
});
756760
});
@@ -770,7 +774,7 @@ describe('OpenFeatureClient', () => {
770774
const client = OpenFeature.getClient();
771775

772776
expect(await client.addHooks().clearHooks().setContext({}).setLogger(console).getBooleanValue('test', true)).toBe(
773-
true
777+
true,
774778
);
775779
});
776780
});

0 commit comments

Comments
 (0)