Repository: open-feature/js-sdk
Pull request #837 (2024-02-27 22:29:36)

feat: context propagation
<!-- 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.



Patch:
diff --git a/packages/nest/src/evaluation-context-propagator.ts b/packages/nest/src/evaluation-context-propagator.ts
deleted file mode 100644
index 8a063639..00000000
--- a/packages/nest/src/evaluation-context-propagator.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { TransactionContextPropagator, EvaluationContext } from '@openfeature/server-sdk';
-import { AsyncLocalStorage } from 'async_hooks';
-
-export class AsyncLocalStorageTransactionContext implements TransactionContextPropagator {
-  private asyncLocalStorage = new AsyncLocalStorage<EvaluationContext>();
-
-  getTransactionContext(): EvaluationContext {
-    return this.asyncLocalStorage.getStore() ?? {};
-  }
-  setTransactionContext(context: EvaluationContext, callback: () => void): void {
-    this.asyncLocalStorage.run(context, callback);
-  }
-}
diff --git a/packages/nest/src/open-feature.module.ts b/packages/nest/src/open-feature.module.ts
index 71b74ef1..77295f85 100644
--- a/packages/nest/src/open-feature.module.ts
+++ b/packages/nest/src/open-feature.module.ts
@@ -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';
 
@@ -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);
@@ -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;
   /**
diff --git a/packages/server/README.md b/packages/server/README.md
index 7e0bde7b..6afd169d 100644
--- a/packages/server/README.md
+++ b/packages/server/README.md
@@ -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>
 
@@ -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
 
@@ -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
@@ -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.
@@ -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
diff --git a/packages/server/src/open-feature.ts b/packages/server/src/open-feature.ts
index 7d8c73cf..4cd0fb46 100644
--- a/packages/server/src/open-feature.ts
+++ b/packages/server/src/open-feature.ts
@@ -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);
   }
diff --git a/packages/server/src/transaction-context/async-local-storage-transaction-context-propagator.ts b/packages/server/src/transaction-context/async-local-storage-transaction-context-propagator.ts
new file mode 100644
index 00000000..0be281e6
--- /dev/null
+++ b/packages/server/src/transaction-context/async-local-storage-transaction-context-propagator.ts
@@ -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);
+  }
+}
diff --git a/packages/server/src/transaction-context/index.ts b/packages/server/src/transaction-context/index.ts
index 41c2fd34..34e976d3 100644
--- a/packages/server/src/transaction-context/index.ts
+++ b/packages/server/src/transaction-context/index.ts
@@ -1,2 +1,3 @@
 export * from './transaction-context';
 export * from './no-op-transaction-context-propagator';
+export * from './async-local-storage-transaction-context-propagator';
diff --git a/packages/server/src/transaction-context/no-op-transaction-context-propagator.ts b/packages/server/src/transaction-context/no-op-transaction-context-propagator.ts
index 7897f6fd..beaf063b 100644
--- a/packages/server/src/transaction-context/no-op-transaction-context-propagator.ts
+++ b/packages/server/src/transaction-context/no-op-transaction-context-propagator.ts
@@ -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);
   }
 }
 
diff --git a/packages/server/src/transaction-context/transaction-context.ts b/packages/server/src/transaction-context/transaction-context.ts
index 925fefb7..5ee79540 100644
--- a/packages/server/src/transaction-context/transaction-context.ts
+++ b/packages/server/src/transaction-context/transaction-context.ts
@@ -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)
@@ -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;
 }


Test patch:
diff --git a/packages/server/test/client.spec.ts b/packages/server/test/client.spec.ts
index ff84b753..1ce45e17 100644
--- a/packages/server/test/client.spec.ts
+++ b/packages/server/test/client.spec.ts
@@ -210,7 +210,7 @@ describe('OpenFeatureClient', () => {
             const defaultStringValue = 'other';
             const value: MyRestrictedString = await client.getStringValue<MyRestrictedString>(
               stringFlag,
-              defaultStringValue
+              defaultStringValue,
             );
 
             expect(value).toEqual(STRING_VALUE);
@@ -238,7 +238,7 @@ describe('OpenFeatureClient', () => {
             const defaultNumberValue = 4096;
             const value: MyRestrictedNumber = await client.getNumberValue<MyRestrictedNumber>(
               numberFlag,
-              defaultNumberValue
+              defaultNumberValue,
             );
 
             expect(value).toEqual(NUMBER_VALUE);
@@ -541,7 +541,7 @@ describe('OpenFeatureClient', () => {
           flagKey,
           defaultValue,
           expect.objectContaining({ transformed: false }),
-          {}
+          {},
         );
       });
     });
@@ -654,7 +654,7 @@ describe('OpenFeatureClient', () => {
           expect.objectContaining({
             targetingKey: TARGETING_KEY,
           }),
-          expect.anything()
+          expect.anything(),
         );
       });
     });
@@ -680,7 +680,7 @@ describe('OpenFeatureClient', () => {
           expect.objectContaining({
             ...context,
           }),
-          expect.anything()
+          expect.anything(),
         );
       });
     });
@@ -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);
           }
         }
 
@@ -750,7 +754,7 @@ describe('OpenFeatureClient', () => {
             ...invocationContext,
             ...beforeHookContext,
           }),
-          expect.anything()
+          expect.anything(),
         );
       });
     });
@@ -770,7 +774,7 @@ describe('OpenFeatureClient', () => {
     const client = OpenFeature.getClient();
 
     expect(await client.addHooks().clearHooks().setContext({}).setLogger(console).getBooleanValue('test', true)).toBe(
-      true
+      true,
     );
   });
 });
diff --git a/packages/server/test/transaction-context.spec.ts b/packages/server/test/transaction-context.spec.ts
new file mode 100644
index 00000000..07288204
--- /dev/null
+++ b/packages/server/test/transaction-context.spec.ts
@@ -0,0 +1,63 @@
+import {
+  OpenFeature,
+  AsyncLocalStorageTransactionContextPropagator,
+  NOOP_TRANSACTION_CONTEXT_PROPAGATOR,
+} from '../src';
+
+describe('AsyncLocalStorageTransactionContextPropagator', () => {
+  beforeAll(() => {
+    OpenFeature.setTransactionContextPropagator(new AsyncLocalStorageTransactionContextPropagator());
+  });
+
+  afterAll(() => {
+    OpenFeature.setTransactionContextPropagator(NOOP_TRANSACTION_CONTEXT_PROPAGATOR);
+  });
+
+  it('should return given transaction context in callback', (done) => {
+    OpenFeature.setTransactionContext({ targetingKey: 'my-key' }, async () => {
+      expect(OpenFeature.getTransactionContext()).toEqual({ targetingKey: 'my-key' });
+      done();
+    });
+  });
+
+  it('should not return transaction context in callstack root', (done) => {
+    let resolve1: (value?: unknown) => unknown = jest.fn();
+    const promise1 = new Promise((resolve) => {
+      resolve1 = resolve;
+    });
+
+    OpenFeature.setTransactionContext({ targetingKey: 'my-key' }, async () => {
+      expect(OpenFeature.getTransactionContext()).toEqual({ targetingKey: 'my-key' });
+      await promise1;
+      done();
+    });
+
+    expect(OpenFeature.getTransactionContext()).toEqual({});
+    resolve1();
+  });
+
+  it('should use the assigned transaction contexts for concurrent promises', (done) => {
+    let resolve1: (value?: unknown) => unknown = jest.fn();
+    const promise1 = new Promise((resolve) => {
+      resolve1 = resolve;
+    });
+
+    let resolve2: (value?: unknown) => unknown = jest.fn();
+    const promise2 = new Promise((resolve) => {
+      resolve2 = resolve;
+    });
+
+    OpenFeature.setTransactionContext({ targetingKey: 'promise-1' }, async () => {
+      expect(OpenFeature.getTransactionContext()).toEqual({ targetingKey: 'promise-1' });
+      resolve2();
+      await promise1;
+      done();
+    });
+
+    OpenFeature.setTransactionContext({ targetingKey: 'promise-2' }, async () => {
+      expect(OpenFeature.getTransactionContext()).toEqual({ targetingKey: 'promise-2' });
+      await promise2;
+      resolve1();
+    });
+  });
+});