Skip to content

Commit

Permalink
feat: context propagation (#837)
Browse files Browse the repository at this point in the history
<!-- 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]>
  • Loading branch information
lukas-reining authored Mar 5, 2024
1 parent 8101ff1 commit b1abef1
Show file tree
Hide file tree
Showing 10 changed files with 174 additions and 60 deletions.
13 changes: 0 additions & 13 deletions packages/nest/src/evaluation-context-propagator.ts

This file was deleted.

6 changes: 3 additions & 3 deletions packages/nest/src/open-feature.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ import {
ServerProviderEvents,
EventHandler,
Logger,
AsyncLocalStorageTransactionContextPropagator,
} from '@openfeature/server-sdk';
import { ContextFactory, ContextFactoryToken } from './context-factory';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { AsyncLocalStorageTransactionContext } from './evaluation-context-propagator';
import { EvaluationContextInterceptor } from './evaluation-context-interceptor';
import { ShutdownService } from './shutdown.service';

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

if (options.logger) {
OpenFeature.setLogger(options.logger);
Expand Down Expand Up @@ -130,7 +130,7 @@ export interface OpenFeatureModuleOptions {
* The {@link ContextFactory} for creating an {@link EvaluationContext} from Nest {@link ExecutionContext} information.
* This could be header values of a request or something similar.
* The context is automatically used for all feature flag evaluations during this request.
* @see {@link AsyncLocalStorageTransactionContext}
* @see {@link AsyncLocalStorageTransactionContextPropagator}
*/
contextFactory?: ContextFactory;
/**
Expand Down
53 changes: 40 additions & 13 deletions packages/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,16 +86,17 @@ See [here](https://open-feature.github.io/js-sdk/modules/_openfeature_server_sdk

## 🌟 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. |
|| [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. |
| 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. |
|| [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. |

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

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

```ts
await OpenFeature.setProviderAndWait(new MyProvider());
```
```

#### Synchronous

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

// Sets a global logger
// Sets a global logger
OpenFeature.setLogger(logger);

// Sets a client logger
Expand Down Expand Up @@ -251,6 +252,32 @@ client.addHandler(ProviderEvents.Error, (eventDetails) => {
});
```

### 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).

The following example shows an Express middleware using transaction context propagation to propagate the request ip and user id into request scoped transaction context.

```ts
import express, { Request, Response, NextFunction } from "express";
import { OpenFeature, AsyncLocalStorageTransactionContextPropagator } from '@openfeature/server-sdk';

OpenFeature.setTransactionContextPropagator(new AsyncLocalStorageTransactionContextPropagator())

/**
* This example is based on an express middleware.
*/
const app = express();
app.use((req: Request, res: Response, next: NextFunction) => {
const ip = res.headers.get("X-Forwarded-For")
OpenFeature.setTransactionContext({ targetingKey: req.user.id, ipAddress: ip }, () => {
// The transaction context is used in any flag evaluation throughout the whole call chain of next
next();
});
})
```

### Shutdown

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

// implement with "new OpenFeatureEventEmitter()", and use "emit()" to emit events
events?: ProviderEventEmitter<AnyProviderEvent> | undefined;
events?: ProviderEventEmitter<AnyProviderEvent> | undefined;

initialize?(context?: EvaluationContext | undefined): Promise<void> {
// code to initialize your provider
Expand Down
6 changes: 3 additions & 3 deletions packages/server/src/open-feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,10 @@ export class OpenFeatureAPI
return this;
}

setTransactionContext<R>(
setTransactionContext<TArgs extends unknown[], R>(
transactionContext: TransactionContext,
callback: (...args: unknown[]) => R,
...args: unknown[]
callback: (...args: TArgs) => R,
...args: TArgs
): void {
this._transactionContextPropagator.setTransactionContext(transactionContext, callback, ...args);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { EvaluationContext } from '@openfeature/core';
import { TransactionContext, TransactionContextPropagator } from './transaction-context';
import { AsyncLocalStorage } from 'async_hooks';

export class AsyncLocalStorageTransactionContextPropagator implements TransactionContextPropagator {
private asyncLocalStorage = new AsyncLocalStorage<EvaluationContext>();

getTransactionContext(): EvaluationContext {
return this.asyncLocalStorage.getStore() ?? {};
}

setTransactionContext<TArgs extends unknown[], R>(
transactionContext: TransactionContext,
callback: (...args: TArgs) => R,
...args: TArgs
): void {
this.asyncLocalStorage.run(transactionContext, callback, ...args);
}
}
1 change: 1 addition & 0 deletions packages/server/src/transaction-context/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './transaction-context';
export * from './no-op-transaction-context-propagator';
export * from './async-local-storage-transaction-context-propagator';
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { EvaluationContext } from '@openfeature/core';
import { TransactionContextPropagator } from './transaction-context';
import { TransactionContext, TransactionContextPropagator } from './transaction-context';

class NoopTransactionContextPropagator implements TransactionContextPropagator {
getTransactionContext(): EvaluationContext {
return {};
}

setTransactionContext(_: EvaluationContext, callback: () => void): void {
callback();
setTransactionContext<TArgs extends unknown[], R>(
_: TransactionContext,
callback: (...args: TArgs) => R,
...args: TArgs
): void {
callback(...args);
}
}

Expand Down
41 changes: 25 additions & 16 deletions packages/server/src/transaction-context/transaction-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,9 @@ export type TransactionContext = EvaluationContext;

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

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

/**
* EXPERIMENTAL: Transaction context propagation is experimental and subject to change.
* The OpenFeature Enhancement Proposal regarding transaction context can be found [here](https://github.com/open-feature/ofep/pull/32).
*
* Sets the transaction context using the registered transaction context propagator.
* @experimental
* Runs the {@link callback} function, in which the {@link transactionContext} will be available by calling
* {@link this#getTransactionContext}.
*
* The {@link TransactionContextPropagator} must persist the {@link transactionContext} and make it available
* to {@link callback} via {@link this#getTransactionContext}.
*
* The precedence of merging context can be seen in {@link https://openfeature.dev/specification/sections/evaluation-context#requirement-323 the specification}.
*
* Example:
*
* ```js
* app.use((req: Request, res: Response, next: NextFunction) => {
* const ip = res.headers.get("X-Forwarded-For")
* OpenFeature.setTransactionContext({ targetingKey: req.user.id, ipAddress: ip }, () => {
* // The transaction context is used in any flag evaluation throughout the whole call chain of next
* next();
* });
* })
*
* ```
* @template TArgs The optional args passed to the callback function
* @template R The return value of the callback
* @param {TransactionContext} transactionContext The transaction specific context
* @param {(...args: unknown[]) => R} callback Callback function used to set the transaction context on the stack
* @param {(...args: unknown[]) => R} callback Callback function to run
* @param {...unknown[]} args Optional arguments that are passed to the callback function
*/
setTransactionContext<R>(
setTransactionContext<TArgs extends unknown[], R>(
transactionContext: TransactionContext,
callback: (...args: unknown[]) => R,
...args: unknown[]
callback: (...args: TArgs) => R,
...args: TArgs
): void;
}
22 changes: 13 additions & 9 deletions packages/server/test/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ describe('OpenFeatureClient', () => {
const defaultStringValue = 'other';
const value: MyRestrictedString = await client.getStringValue<MyRestrictedString>(
stringFlag,
defaultStringValue
defaultStringValue,
);

expect(value).toEqual(STRING_VALUE);
Expand Down Expand Up @@ -238,7 +238,7 @@ describe('OpenFeatureClient', () => {
const defaultNumberValue = 4096;
const value: MyRestrictedNumber = await client.getNumberValue<MyRestrictedNumber>(
numberFlag,
defaultNumberValue
defaultNumberValue,
);

expect(value).toEqual(NUMBER_VALUE);
Expand Down Expand Up @@ -541,7 +541,7 @@ describe('OpenFeatureClient', () => {
flagKey,
defaultValue,
expect.objectContaining({ transformed: false }),
{}
{},
);
});
});
Expand Down Expand Up @@ -654,7 +654,7 @@ describe('OpenFeatureClient', () => {
expect.objectContaining({
targetingKey: TARGETING_KEY,
}),
expect.anything()
expect.anything(),
);
});
});
Expand All @@ -680,7 +680,7 @@ describe('OpenFeatureClient', () => {
expect.objectContaining({
...context,
}),
expect.anything()
expect.anything(),
);
});
});
Expand Down Expand Up @@ -724,9 +724,13 @@ describe('OpenFeatureClient', () => {
return this.context;
}

setTransactionContext(transactionContext: EvaluationContext, callback: () => void): void {
setTransactionContext<TArgs extends unknown[], R>(
transactionContext: TransactionContext,
callback: (...args: TArgs) => R,
...args: TArgs
): void {
this.context = transactionContext;
callback();
callback(...args);
}
}

Expand All @@ -750,7 +754,7 @@ describe('OpenFeatureClient', () => {
...invocationContext,
...beforeHookContext,
}),
expect.anything()
expect.anything(),
);
});
});
Expand All @@ -770,7 +774,7 @@ describe('OpenFeatureClient', () => {
const client = OpenFeature.getClient();

expect(await client.addHooks().clearHooks().setContext({}).setLogger(console).getBooleanValue('test', true)).toBe(
true
true,
);
});
});
Loading

0 comments on commit b1abef1

Please sign in to comment.