Repository: open-feature/js-sdk
Pull request #1020 (2024-09-20 19:20:30)

feat: implement tracking as per spec
:mega: This was a draft for a while, but is now ready for review! :mega: 

This implements tracking as per spec, in the server, web, and react SDKs.
I don't think the Angular or Nest SDKs need specific implementations, but please advise (cc @luizgribeiro @lukas-reining).

Fixes: https://github.com/open-feature/js-sdk/issues/1033
Fixes: https://github.com/open-feature/js-sdk/issues/1034

toddbaert: This is ready for review now.
toddbaert: > Lgtm. Would be nice to hook it up to @beeme1mr s test app?

That's the plan!
toddbaert: I'm merge this tomorrow unless I hear objections 

Patch:
diff --git a/packages/react/src/context/use-context-mutator.ts b/packages/react/src/context/use-context-mutator.ts
index 420e923c..2667f186 100644
--- a/packages/react/src/context/use-context-mutator.ts
+++ b/packages/react/src/context/use-context-mutator.ts
@@ -15,7 +15,7 @@ export type ContextMutationOptions = {
 
 export type ContextMutation = {
   /**
-   * A function to set the desired context (see: {@link ContextMutationOptions} for details).
+   * Context-aware function to set the desired context (see: {@link ContextMutationOptions} for details).
    * There's generally no need to await the result of this function; flag evaluation hooks will re-render when the context is updated.
    * This promise never rejects.
    * @param updatedContext
@@ -25,10 +25,10 @@ export type ContextMutation = {
 };
 
 /**
- * Get function(s) for mutating the evaluation context associated with this domain, or the default context if `defaultContext: true`.
+ * Get context-aware tracking function(s) for mutating the evaluation context associated with this domain, or the default context if `defaultContext: true`.
  * See the {@link https://openfeature.dev/docs/reference/technologies/client/web/#targeting-and-context|documentation} for more information.
  * @param {ContextMutationOptions} options options for the generated function
- * @returns {ContextMutation} function(s) to mutate context
+ * @returns {ContextMutation} context-aware function(s) to mutate evaluation context
  */
 export function useContextMutator(options: ContextMutationOptions = { defaultContext: false }): ContextMutation {
     const { domain } = useContext(Context) || {};
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 6690ab35..e2f902c5 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -2,5 +2,6 @@ export * from './evaluation';
 export * from './query';
 export * from './provider';
 export * from './context';
+export * from './tracking';
 // re-export the web-sdk so consumers can access that API from the react-sdk
 export * from '@openfeature/web-sdk';
diff --git a/packages/react/src/tracking/index.ts b/packages/react/src/tracking/index.ts
new file mode 100644
index 00000000..8a639f15
--- /dev/null
+++ b/packages/react/src/tracking/index.ts
@@ -0,0 +1,1 @@
+export * from './use-track';
diff --git a/packages/react/src/tracking/use-track.ts b/packages/react/src/tracking/use-track.ts
new file mode 100644
index 00000000..9fad805c
--- /dev/null
+++ b/packages/react/src/tracking/use-track.ts
@@ -0,0 +1,29 @@
+import type { Tracking, TrackingEventDetails } from '@openfeature/web-sdk';
+import { useCallback } from 'react';
+import { useOpenFeatureClient } from '../provider';
+
+export type Track = {
+  /**
+   * Context-aware tracking function for the parent `<OpenFeatureProvider/>`.
+   * Track a user action or application state, usually representing a business objective or outcome.
+   * @param trackingEventName an identifier for the event
+   * @param trackingEventDetails the details of the tracking event
+   */
+  track: Tracking['track'];
+};
+
+/**
+ * Get a context-aware tracking function.
+ * @returns {Track} context-aware tracking
+ */
+export function useTrack(): Track {
+  const client = useOpenFeatureClient();
+
+  const track = useCallback((trackingEventName: string, trackingEventDetails?: TrackingEventDetails) => {
+    client.track(trackingEventName, trackingEventDetails);
+  }, []);
+
+  return {
+    track,
+  };
+}
diff --git a/packages/server/src/client/client.ts b/packages/server/src/client/client.ts
index 9efe1220..0446246b 100644
--- a/packages/server/src/client/client.ts
+++ b/packages/server/src/client/client.ts
@@ -8,12 +8,14 @@ import type {
 import type { Features } from '../evaluation';
 import type { ProviderStatus } from '../provider';
 import type { ProviderEvents } from '../events';
+import type { Tracking } from '../tracking';
 
 export interface Client
   extends EvaluationLifeCycle<Client>,
     Features,
     ManageContext<Client>,
     ManageLogger<Client>,
+    Tracking,
     Eventing<ProviderEvents> {
   readonly metadata: ClientMetadata;
   /**
diff --git a/packages/server/src/client/internal/open-feature-client.ts b/packages/server/src/client/internal/open-feature-client.ts
index 5678c808..46a5a75b 100644
--- a/packages/server/src/client/internal/open-feature-client.ts
+++ b/packages/server/src/client/internal/open-feature-client.ts
@@ -8,6 +8,7 @@ import type {
   HookContext,
   JsonValue,
   Logger,
+  TrackingEventDetails,
   OpenFeatureError,
   ResolutionDetails} from '@openfeature/core';
 import {
@@ -23,7 +24,6 @@ import type { FlagEvaluationOptions } from '../../evaluation';
 import type { ProviderEvents } from '../../events';
 import type { InternalEventEmitter } from '../../events/internal/internal-event-emitter';
 import type { Hook } from '../../hooks';
-import { OpenFeature } from '../../open-feature';
 import type { Provider} from '../../provider';
 import { ProviderStatus } from '../../provider';
 import type { Client } from './../client';
@@ -53,6 +53,9 @@ export class OpenFeatureClient implements Client {
     private readonly providerAccessor: () => Provider,
     private readonly providerStatusAccessor: () => ProviderStatus,
     private readonly emitterAccessor: () => InternalEventEmitter,
+    private readonly apiContextAccessor: () => EvaluationContext,
+    private readonly apiHooksAccessor: () => Hook[],
+    private readonly transactionContextAccessor: () => EvaluationContext,
     private readonly globalLogger: () => Logger,
     private readonly options: OpenFeatureClientOptions,
     context: EvaluationContext = {},
@@ -223,6 +226,22 @@ export class OpenFeatureClient implements Client {
     return this.evaluate<T>(flagKey, this._provider.resolveObjectEvaluation, defaultValue, 'object', context, options);
   }
 
+  track(occurrenceKey: string, context: EvaluationContext, occurrenceDetails: TrackingEventDetails): void {
+    try {
+      this.shortCircuitIfNotReady();
+
+      if (typeof this._provider.track === 'function') {
+        // freeze the merged context
+        const frozenContext = Object.freeze(this.mergeContexts(context));
+        return this._provider.track?.(occurrenceKey, frozenContext, occurrenceDetails);
+      } else {
+        this._logger.debug('Provider does not support the track function; will no-op.');
+      }
+    } catch (err) {
+      this._logger.debug('Error recording tracking event.', err);
+    }
+  }
+
   private async evaluate<T extends FlagValue>(
     flagKey: string,
     resolver: (
@@ -239,20 +258,14 @@ export class OpenFeatureClient implements Client {
     // merge global, client, and evaluation context
 
     const allHooks = [
-      ...OpenFeature.getHooks(),
+      ...this.apiHooksAccessor(),
       ...this.getHooks(),
       ...(options.hooks || []),
       ...(this._provider.hooks || []),
     ];
     const allHooksReversed = [...allHooks].reverse();
 
-    // merge global and client contexts
-    const mergedContext = {
-      ...OpenFeature.getContext(),
-      ...OpenFeature.getTransactionContext(),
-      ...this._context,
-      ...invocationContext,
-    };
+    const mergedContext = this.mergeContexts(invocationContext);
 
     // this reference cannot change during the course of evaluation
     // it may be used as a key in WeakMaps
@@ -269,12 +282,7 @@ export class OpenFeatureClient implements Client {
     try {
       const frozenContext = await this.beforeHooks(allHooks, hookContext, options);
 
-      // short circuit evaluation entirely if provider is in a bad state
-      if (this.providerStatus === ProviderStatus.NOT_READY) {
-        throw new ProviderNotReadyError('provider has not yet initialized');
-      } else if (this.providerStatus === ProviderStatus.FATAL) {
-        throw new ProviderFatalError('provider is in an irrecoverable error state');
-      }
+      this.shortCircuitIfNotReady();
 
       // run the referenced resolver, binding the provider.
       const resolution = await resolver.call(this._provider, flagKey, defaultValue, frozenContext, this._logger);
@@ -380,4 +388,23 @@ export class OpenFeatureClient implements Client {
   private get _logger() {
     return this._clientLogger || this.globalLogger();
   }
+
+  private mergeContexts(invocationContext: EvaluationContext) {
+    // merge global and client contexts
+    return {
+      ...this.apiContextAccessor(),
+      ...this.transactionContextAccessor(),
+      ...this._context,
+      ...invocationContext,
+    };
+  }
+
+  private shortCircuitIfNotReady() {
+    // short circuit evaluation entirely if provider is in a bad state
+    if (this.providerStatus === ProviderStatus.NOT_READY) {
+      throw new ProviderNotReadyError('provider has not yet initialized');
+    } else if (this.providerStatus === ProviderStatus.FATAL) {
+      throw new ProviderFatalError('provider is in an irrecoverable error state');
+    }
+  }
 }
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index c229d16f..249135cb 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -5,4 +5,5 @@ export * from './open-feature';
 export * from './transaction-context';
 export * from './events';
 export * from './hooks';
+export * from './tracking';
 export * from '@openfeature/core';
diff --git a/packages/server/src/open-feature.ts b/packages/server/src/open-feature.ts
index 995c0884..ae4b439f 100644
--- a/packages/server/src/open-feature.ts
+++ b/packages/server/src/open-feature.ts
@@ -198,6 +198,9 @@ export class OpenFeatureAPI
       () => this.getProviderForClient(domain),
       () => this.getProviderStatus(domain),
       () => this.buildAndCacheEventEmitterForClient(domain),
+      () => this.getContext(),
+      () => this.getHooks(),
+      () => this.getTransactionContext(),
       () => this._logger,
       { domain, version },
       context,
diff --git a/packages/server/src/provider/provider.ts b/packages/server/src/provider/provider.ts
index 1c001a83..4c97f177 100644
--- a/packages/server/src/provider/provider.ts
+++ b/packages/server/src/provider/provider.ts
@@ -1,5 +1,12 @@
-import type { CommonProvider, EvaluationContext, JsonValue, Logger, ResolutionDetails} from '@openfeature/core';
-import { ServerProviderStatus } from '@openfeature/core';
+import type {
+  CommonProvider,
+  EvaluationContext,
+  JsonValue,
+  Logger,
+  ResolutionDetails} from '@openfeature/core';
+import {
+  ServerProviderStatus,
+} from '@openfeature/core';
 import type { Hook } from '../hooks';
 
 export { ServerProviderStatus as ProviderStatus };
diff --git a/packages/server/src/tracking/index.ts b/packages/server/src/tracking/index.ts
new file mode 100644
index 00000000..5794a1d4
--- /dev/null
+++ b/packages/server/src/tracking/index.ts
@@ -0,0 +1,1 @@
+export * from './tracking';
diff --git a/packages/server/src/tracking/tracking.ts b/packages/server/src/tracking/tracking.ts
new file mode 100644
index 00000000..b7ef17e9
--- /dev/null
+++ b/packages/server/src/tracking/tracking.ts
@@ -0,0 +1,12 @@
+import type { EvaluationContext, TrackingEventDetails } from '@openfeature/core';
+
+export interface Tracking {
+
+  /**
+   * Track a user action or application state, usually representing a business objective or outcome.
+   * @param trackingEventName an identifier for the event
+   * @param context the evaluation context
+   * @param trackingEventDetails the details of the tracking event
+   */
+  track(trackingEventName: string, context?: EvaluationContext, trackingEventDetails?: TrackingEventDetails): void;
+}
diff --git a/packages/shared/src/evaluation/context.ts b/packages/shared/src/evaluation/context.ts
index 0412fd2a..db4f9597 100644
--- a/packages/shared/src/evaluation/context.ts
+++ b/packages/shared/src/evaluation/context.ts
@@ -1,4 +1,4 @@
-import type { PrimitiveValue } from './evaluation';
+import type { PrimitiveValue } from '../types';
 
 export type EvaluationContextValue =
   | PrimitiveValue
diff --git a/packages/shared/src/evaluation/evaluation.ts b/packages/shared/src/evaluation/evaluation.ts
index c900b40f..895a1ddc 100644
--- a/packages/shared/src/evaluation/evaluation.ts
+++ b/packages/shared/src/evaluation/evaluation.ts
@@ -1,13 +1,6 @@
-export type FlagValueType = 'boolean' | 'string' | 'number' | 'object';
-
-export type PrimitiveValue = null | boolean | string | number;
-export type JsonObject = { [key: string]: JsonValue };
-export type JsonArray = JsonValue[];
+import type { JsonValue } from '../types/structure';
 
-/**
- * Represents a JSON node value.
- */
-export type JsonValue = PrimitiveValue | JsonObject | JsonArray;
+export type FlagValueType = 'boolean' | 'string' | 'number' | 'object';
 
 /**
  * Represents a JSON node value, or Date.
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 8a85f414..42c1504a 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -7,4 +7,5 @@ export * from './logger';
 export * from './provider';
 export * from './evaluation';
 export * from './type-guards';
+export * from './tracking';
 export * from './open-feature';
diff --git a/packages/shared/src/provider/provider.ts b/packages/shared/src/provider/provider.ts
index 06f86908..e964f1b8 100644
--- a/packages/shared/src/provider/provider.ts
+++ b/packages/shared/src/provider/provider.ts
@@ -1,5 +1,6 @@
 import type { EvaluationContext } from '../evaluation';
 import type { AnyProviderEvent, ProviderEventEmitter } from '../events';
+import type { TrackingEventDetails } from '../tracking';
 import type { Metadata, Paradigm } from '../types';
 
 // TODO: with TypeScript 5+, we can use computed string properties,
@@ -125,4 +126,12 @@ export interface CommonProvider<S extends ClientProviderStatus | ServerProviderS
    * @param context
    */
   initialize?(context?: EvaluationContext): Promise<void>;
+
+  /**
+   * Track a user action or application state, usually representing a business objective or outcome.
+   * @param trackingEventName
+   * @param context
+   * @param trackingEventDetails
+   */
+  track?(trackingEventName: string, context?: EvaluationContext, trackingEventDetails?: TrackingEventDetails): void;
 }
diff --git a/packages/shared/src/tracking/index.ts b/packages/shared/src/tracking/index.ts
new file mode 100644
index 00000000..41426f0b
--- /dev/null
+++ b/packages/shared/src/tracking/index.ts
@@ -0,0 +1,1 @@
+export * from './tracking-event';
diff --git a/packages/shared/src/tracking/tracking-event.ts b/packages/shared/src/tracking/tracking-event.ts
new file mode 100644
index 00000000..cfe008bd
--- /dev/null
+++ b/packages/shared/src/tracking/tracking-event.ts
@@ -0,0 +1,17 @@
+import type { PrimitiveValue } from '../types';
+
+export type TrackingEventValue =
+  | PrimitiveValue
+  | Date
+  | { [key: string]: TrackingEventValue }
+  | TrackingEventValue[];
+
+/**
+ * A container for arbitrary data that can relevant to tracking events.
+ */
+export type TrackingEventDetails = {
+  /**
+   * A numeric value associated with this event.
+   */
+  value?: number;
+} & Record<string, TrackingEventValue>;
diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts
index 69cf2f63..6c96c078 100644
--- a/packages/shared/src/types/index.ts
+++ b/packages/shared/src/types/index.ts
@@ -1,2 +1,3 @@
 export * from './metadata';
-export * from './paradigm';
\ No newline at end of file
+export * from './paradigm';
+export * from './structure';
diff --git a/packages/shared/src/types/structure.ts b/packages/shared/src/types/structure.ts
new file mode 100644
index 00000000..7edbdd93
--- /dev/null
+++ b/packages/shared/src/types/structure.ts
@@ -0,0 +1,7 @@
+export type PrimitiveValue = null | boolean | string | number;
+export type JsonObject = { [key: string]: JsonValue };
+export type JsonArray = JsonValue[];
+/**
+ * Represents a JSON node value.
+ */
+export type JsonValue = PrimitiveValue | JsonObject | JsonArray;
diff --git a/packages/web/src/client/client.ts b/packages/web/src/client/client.ts
index 2cb14b49..be3f058a 100644
--- a/packages/web/src/client/client.ts
+++ b/packages/web/src/client/client.ts
@@ -2,8 +2,14 @@ import type { ClientMetadata, EvaluationLifeCycle, Eventing, ManageLogger } from
 import type { Features } from '../evaluation';
 import type { ProviderStatus } from '../provider';
 import type { ProviderEvents } from '../events';
+import type { Tracking } from '../tracking';
 
-export interface Client extends EvaluationLifeCycle<Client>, Features, ManageLogger<Client>, Eventing<ProviderEvents> {
+export interface Client
+  extends EvaluationLifeCycle<Client>,
+    Features,
+    ManageLogger<Client>,
+    Eventing<ProviderEvents>,
+    Tracking {
   readonly metadata: ClientMetadata;
   /**
    * Returns the status of the associated provider.
diff --git a/packages/web/src/client/internal/open-feature-client.ts b/packages/web/src/client/internal/open-feature-client.ts
index 600a6d71..d7189226 100644
--- a/packages/web/src/client/internal/open-feature-client.ts
+++ b/packages/web/src/client/internal/open-feature-client.ts
@@ -8,8 +8,9 @@ import type {
   HookContext,
   JsonValue,
   Logger,
+  TrackingEventDetails,
   OpenFeatureError,
-  ResolutionDetails} from '@openfeature/core';
+  ResolutionDetails } from '@openfeature/core';
 import {
   ErrorCode,
   ProviderFatalError,
@@ -23,7 +24,6 @@ import type { FlagEvaluationOptions } from '../../evaluation';
 import type { ProviderEvents } from '../../events';
 import type { InternalEventEmitter } from '../../events/internal/internal-event-emitter';
 import type { Hook } from '../../hooks';
-import { OpenFeature } from '../../open-feature';
 import type { Provider} from '../../provider';
 import { ProviderStatus } from '../../provider';
 import type { Client } from './../client';
@@ -52,6 +52,8 @@ export class OpenFeatureClient implements Client {
     private readonly providerAccessor: () => Provider,
     private readonly providerStatusAccessor: () => ProviderStatus,
     private readonly emitterAccessor: () => InternalEventEmitter,
+    private readonly apiContextAccessor: (domain?: string) => EvaluationContext,
+    private readonly apiHooksAccessor: () => Hook[],
     private readonly globalLogger: () => Logger,
     private readonly options: OpenFeatureClientOptions,
   ) {}
@@ -181,6 +183,24 @@ export class OpenFeatureClient implements Client {
     return this.evaluate<T>(flagKey, this._provider.resolveObjectEvaluation, defaultValue, 'object', options);
   }
 
+  track(occurrenceKey: string, occurrenceDetails: TrackingEventDetails): void {
+    try {
+      this.shortCircuitIfNotReady();
+
+      if (typeof this._provider.track === 'function') {
+        // copy and freeze the context
+        const frozenContext = Object.freeze({
+          ...this.apiContextAccessor(this?.options?.domain),
+        });
+        return this._provider.track?.(occurrenceKey, frozenContext, occurrenceDetails);
+      } else {
+        this._logger.debug('Provider does not support the track function; will no-op.');
+      }
+    } catch (err) {
+      this._logger.debug('Error recording tracking event.', err);
+    }
+  }
+
   private evaluate<T extends FlagValue>(
     flagKey: string,
     resolver: (flagKey: string, defaultValue: T, context: EvaluationContext, logger: Logger) => ResolutionDetails<T>,
@@ -191,7 +211,7 @@ export class OpenFeatureClient implements Client {
     // merge global, client, and evaluation context
 
     const allHooks = [
-      ...OpenFeature.getHooks(),
+      ...this.apiHooksAccessor(),
       ...this.getHooks(),
       ...(options.hooks || []),
       ...(this._provider.hooks || []),
@@ -199,7 +219,7 @@ export class OpenFeatureClient implements Client {
     const allHooksReversed = [...allHooks].reverse();
 
     const context = {
-      ...OpenFeature.getContext(this?.options?.domain),
+      ...this.apiContextAccessor(this?.options?.domain),
     };
 
     // this reference cannot change during the course of evaluation
@@ -217,12 +237,7 @@ export class OpenFeatureClient implements Client {
     try {
       this.beforeHooks(allHooks, hookContext, options);
 
-      // short circuit evaluation entirely if provider is in a bad state
-      if (this.providerStatus === ProviderStatus.NOT_READY) {
-        throw new ProviderNotReadyError('provider has not yet initialized');
-      } else if (this.providerStatus === ProviderStatus.FATAL) {
-        throw new ProviderFatalError('provider is in an irrecoverable error state');
-      }
+      this.shortCircuitIfNotReady();
 
       // run the referenced resolver, binding the provider.
       const resolution = resolver.call(this._provider, flagKey, defaultValue, context, this._logger);
@@ -317,4 +332,13 @@ export class OpenFeatureClient implements Client {
   private get _logger() {
     return this._clientLogger || this.globalLogger();
   }
+
+  private shortCircuitIfNotReady() {
+    // short circuit evaluation entirely if provider is in a bad state
+    if (this.providerStatus === ProviderStatus.NOT_READY) {
+      throw new ProviderNotReadyError('provider has not yet initialized');
+    } else if (this.providerStatus === ProviderStatus.FATAL) {
+      throw new ProviderFatalError('provider is in an irrecoverable error state');
+    }
+  }
 }
diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts
index 355cd9ce..fa481b73 100644
--- a/packages/web/src/index.ts
+++ b/packages/web/src/index.ts
@@ -4,4 +4,5 @@ export * from './evaluation';
 export * from './open-feature';
 export * from './events';
 export * from './hooks';
+export * from './tracking';
 export * from '@openfeature/core';
diff --git a/packages/web/src/open-feature.ts b/packages/web/src/open-feature.ts
index 27c1f561..9c35a31a 100644
--- a/packages/web/src/open-feature.ts
+++ b/packages/web/src/open-feature.ts
@@ -342,6 +342,8 @@ export class OpenFeatureAPI
       () => this.getProviderForClient(domain),
       () => this.getProviderStatus(domain),
       () => this.buildAndCacheEventEmitterForClient(domain),
+      (domain?: string) => this.getContext(domain),
+      () => this.getHooks(),
       () => this._logger,
       { domain, version },
     );
diff --git a/packages/web/src/provider/in-memory-provider/in-memory-provider.ts b/packages/web/src/provider/in-memory-provider/in-memory-provider.ts
index 26690a56..3af11cc4 100644
--- a/packages/web/src/provider/in-memory-provider/in-memory-provider.ts
+++ b/packages/web/src/provider/in-memory-provider/in-memory-provider.ts
@@ -38,8 +38,7 @@ export class InMemoryProvider implements Provider {
    */
   async putConfiguration(flagConfiguration: FlagConfiguration) {
     try {
-      const flagsChanged = Object.entries(flagConfiguration)
-        .filter(([key, value]) => this._flagConfiguration[key] !== value)
+      const flagsChanged = Object.entries({...flagConfiguration, ...this._flagConfiguration})
         .map(([key]) => key);
 
       this._flagConfiguration = { ...flagConfiguration };
diff --git a/packages/web/src/provider/provider.ts b/packages/web/src/provider/provider.ts
index 03c6d8cb..12785408 100644
--- a/packages/web/src/provider/provider.ts
+++ b/packages/web/src/provider/provider.ts
@@ -1,4 +1,10 @@
-import type { CommonProvider, EvaluationContext, JsonValue, Logger, ResolutionDetails } from '@openfeature/core';
+import type {
+  CommonProvider,
+  EvaluationContext,
+  JsonValue,
+  Logger,
+  ResolutionDetails,
+} from '@openfeature/core';
 import { ClientProviderStatus } from '@openfeature/core';
 import type { Hook } from '../hooks';
 
diff --git a/packages/web/src/tracking/index.ts b/packages/web/src/tracking/index.ts
new file mode 100644
index 00000000..5794a1d4
--- /dev/null
+++ b/packages/web/src/tracking/index.ts
@@ -0,0 +1,1 @@
+export * from './tracking';
diff --git a/packages/web/src/tracking/tracking.ts b/packages/web/src/tracking/tracking.ts
new file mode 100644
index 00000000..b37c45b9
--- /dev/null
+++ b/packages/web/src/tracking/tracking.ts
@@ -0,0 +1,11 @@
+import type { TrackingEventDetails } from '@openfeature/core';
+
+export interface Tracking {
+
+  /**
+   * Track a user action or application state, usually representing a business objective or outcome.
+   * @param trackingEventName an identifier for the event
+   * @param trackingEventDetails the details of the tracking event
+   */
+  track(trackingEventName: string, trackingEventDetails?: TrackingEventDetails): void;
+}


Test patch:
diff --git a/packages/react/test/tracking.spec.tsx b/packages/react/test/tracking.spec.tsx
new file mode 100644
index 00000000..2b4779f6
--- /dev/null
+++ b/packages/react/test/tracking.spec.tsx
@@ -0,0 +1,88 @@
+import { jest } from '@jest/globals';
+import '@testing-library/jest-dom'; // see: https://testing-library.com/docs/react-testing-library/setup
+import { render } from '@testing-library/react';
+import * as React from 'react';
+import type { Provider, TrackingEventDetails } from '../src';
+import {
+  OpenFeature,
+  OpenFeatureProvider,
+  useTrack
+} from '../src';
+
+describe('tracking', () => {
+
+  const eventName = 'test-tracking-event';
+  const trackingValue = 1234;
+  const trackingDetails: TrackingEventDetails = {
+    value: trackingValue,
+  };
+  const domain = 'someDomain';
+
+  const mockProvider = () => {
+    const mockProvider: Provider = {
+      metadata: {
+        name: 'mock',
+      },
+
+      track: jest.fn((): void => {
+        return;
+      }),
+    } as unknown as Provider;
+
+    return mockProvider;
+  };
+
+  describe('no domain', () => {
+    it('should call default provider', async () => {
+
+      const provider = mockProvider();
+      await OpenFeature.setProviderAndWait(provider);
+
+      function Component() {
+        const { track } = useTrack();
+        track(eventName, trackingDetails);
+
+        return <div></div>;
+      }
+
+      render(
+        <OpenFeatureProvider suspend={false} >
+            <Component></Component>
+        </OpenFeatureProvider>,
+      );
+
+      expect(provider.track).toHaveBeenCalledWith(
+        eventName,
+        expect.anything(),
+        expect.objectContaining({ value: trackingValue }),
+      );
+    });
+  });
+
+  describe('domain set', () => {
+    it('should call provider for domain', async () => {
+
+      const domainProvider = mockProvider();
+      await OpenFeature.setProviderAndWait(domain, domainProvider);
+
+      function Component() {
+        const { track } = useTrack();
+        track(eventName, trackingDetails);
+
+        return <div></div>;
+      }
+
+      render(
+        <OpenFeatureProvider domain={domain} suspend={false} >
+            <Component></Component>
+        </OpenFeatureProvider>,
+      );
+
+      expect(domainProvider.track).toHaveBeenCalledWith(
+        eventName,
+        expect.anything(),
+        expect.objectContaining({ value: trackingValue }),
+      );
+    });
+  });
+});
diff --git a/packages/server/test/client.spec.ts b/packages/server/test/client.spec.ts
index e6604848..69dc41fa 100644
--- a/packages/server/test/client.spec.ts
+++ b/packages/server/test/client.spec.ts
@@ -10,18 +10,20 @@ import type {
   Provider,
   ResolutionDetails,
   TransactionContext,
-  TransactionContextPropagator} from '../src';
+  TransactionContextPropagator,
+} from '../src';
 import {
   ErrorCode,
   FlagNotFoundError,
   OpenFeature,
   ProviderFatalError,
   ProviderStatus,
-  StandardResolutionReasons
+  StandardResolutionReasons,
 } from '../src';
 import { OpenFeatureClient } from '../src/client/internal/open-feature-client';
 import { isDeepStrictEqual } from 'node:util';
 import type { HookContext } from '@openfeature/core';
+import type { TrackingEventDetails } from '@openfeature/core';
 
 const BOOLEAN_VALUE = true;
 const STRING_VALUE = 'val';
@@ -57,6 +59,11 @@ const MOCK_PROVIDER: Provider = {
   metadata: {
     name: 'mock',
   },
+
+  track: jest.fn((): void => {
+    return;
+  }),
+
   resolveBooleanEvaluation: jest.fn((): Promise<ResolutionDetails<boolean>> => {
     return Promise.resolve({
       value: BOOLEAN_VALUE,
@@ -64,6 +71,7 @@ const MOCK_PROVIDER: Provider = {
       reason: REASON,
     });
   }),
+
   resolveStringEvaluation: jest.fn(<U extends string>(): Promise<ResolutionDetails<U>> => {
     return Promise.resolve({
       value: STRING_VALUE,
@@ -71,6 +79,7 @@ const MOCK_PROVIDER: Provider = {
       reason: REASON,
     }) as Promise<ResolutionDetails<U>>;
   }) as <U>() => Promise<ResolutionDetails<U>>,
+
   resolveNumberEvaluation: jest.fn((): Promise<ResolutionDetails<number>> => {
     return Promise.resolve({
       value: NUMBER_VALUE,
@@ -78,6 +87,7 @@ const MOCK_PROVIDER: Provider = {
       reason: REASON,
     });
   }),
+
   resolveObjectEvaluation: jest.fn(<U extends JsonValue>(): Promise<ResolutionDetails<U>> => {
     const details = Promise.resolve<ResolutionDetails<U>>({
       value: OBJECT_VALUE as U,
@@ -558,7 +568,7 @@ describe('OpenFeatureClient', () => {
       },
       initialize: () => {
         return Promise.resolve();
-      }
+      },
     } as unknown as Provider;
     it('status must be READY if init resolves', async () => {
       await OpenFeature.setProviderAndWait('1.7.1, 1.7.3', initProvider);
@@ -574,7 +584,7 @@ describe('OpenFeatureClient', () => {
       },
       initialize: async () => {
         return Promise.reject(new GeneralError());
-      }
+      },
     } as unknown as Provider;
     it('status must be ERROR if init rejects', async () => {
       await expect(OpenFeature.setProviderAndWait('1.7.4', errorProvider)).rejects.toThrow();
@@ -590,7 +600,7 @@ describe('OpenFeatureClient', () => {
       },
       initialize: () => {
         return Promise.reject(new ProviderFatalError());
-      }
+      },
     } as unknown as Provider;
     it('must short circuit and return PROVIDER_FATAL code if provider FATAL', async () => {
       await expect(OpenFeature.setProviderAndWait('1.7.5, 1.7.6, 1.7.8', fatalProvider)).rejects.toThrow();
@@ -613,7 +623,7 @@ describe('OpenFeatureClient', () => {
         return new Promise(() => {
           return; // promise never resolves
         });
-      }
+      },
     } as unknown as Provider;
     it('must short circuit and return PROVIDER_NOT_READY code if provider NOT_READY', async () => {
       OpenFeature.setProviderAndWait('1.7.7', neverReadyProvider).catch(() => {
@@ -747,25 +757,29 @@ describe('OpenFeatureClient', () => {
         const hook = {
           before: jest.fn((hookContext: HookContext) => {
             // we have to put this assertion here because of limitations in jest with expect.objectContaining and mutability
-            if (isDeepStrictEqual(hookContext.context, {
-              ...globalContext,
-              ...transactionContext,
-              ...clientContext,
-              ...invocationContext,
-              // before hook context should be missing here (and not overridden)
-            })) {
+            if (
+              isDeepStrictEqual(hookContext.context, {
+                ...globalContext,
+                ...transactionContext,
+                ...clientContext,
+                ...invocationContext,
+                // before hook context should be missing here (and not overridden)
+              })
+            ) {
               return beforeHookContext;
             }
           }),
           after: jest.fn((hookContext: HookContext) => {
             // we have to put this assertion here because of limitations in jest with expect.objectContaining and mutability
-            if (isDeepStrictEqual(hookContext.context, {
-              ...globalContext,
-              ...transactionContext,
-              ...clientContext,
-              ...invocationContext,
-              ...beforeHookContext,
-            })) {
+            if (
+              isDeepStrictEqual(hookContext.context, {
+                ...globalContext,
+                ...transactionContext,
+                ...clientContext,
+                ...invocationContext,
+                ...beforeHookContext,
+              })
+            ) {
               return beforeHookContext;
             }
           }),
@@ -807,4 +821,61 @@ describe('OpenFeatureClient', () => {
       true,
     );
   });
+
+  describe('tracking', () => {
+    describe('Requirement 2.7.1, Requirement 6.1.2.1', () => {
+      const eventName = 'test-tracking-event';
+      const trackingValue = 1234;
+      const trackingDetails: TrackingEventDetails = {
+        value: trackingValue,
+      };
+      const globalContextKey = 'globalKey';
+      const clientContextKey = 'clientKey';
+      const invocationContextKey = 'invocationKey';
+      const globalContextValue = 'globalValue';
+      const clientContextValue = 'clientValue';
+      const invocationContextValue = 'invocationValue';
+
+      it('should no-op and not throw if tracking not defined on provider', async () => {
+        await OpenFeature.setProviderAndWait({ ...MOCK_PROVIDER, track: undefined });
+        const client = OpenFeature.getClient();
+
+        expect(() => {
+          client.track(eventName, {}, trackingDetails);
+        }).not.toThrow();
+      });
+
+      it('should no-op and not throw if provider throws', async () => {
+        await OpenFeature.setProviderAndWait({
+          ...MOCK_PROVIDER,
+          track: () => {
+            throw new Error('fake error');
+          },
+        });
+        const client = OpenFeature.getClient();
+
+        expect(() => {
+          client.track(eventName, {}, trackingDetails);
+        }).not.toThrow();
+      });
+
+      it('should call provider with correct context', async () => {
+        await OpenFeature.setProviderAndWait({ ...MOCK_PROVIDER });
+        OpenFeature.setContext({ [globalContextKey]: globalContextValue });
+        const client = OpenFeature.getClient();
+        client.setContext({ [clientContextKey]: clientContextValue });
+        client.track(eventName, { [invocationContextKey]: invocationContextValue }, trackingDetails);
+
+        expect(MOCK_PROVIDER.track).toHaveBeenCalledWith(
+          eventName,
+          expect.objectContaining({
+            [globalContextKey]: globalContextValue,
+            [clientContextKey]: clientContextValue,
+            [invocationContextKey]: invocationContextValue,
+          }),
+          expect.objectContaining({ value: trackingValue }),
+        );
+      });
+    });
+  });
 });
diff --git a/packages/web/test/client.spec.ts b/packages/web/test/client.spec.ts
index 3fd93162..381dd9b2 100644
--- a/packages/web/test/client.spec.ts
+++ b/packages/web/test/client.spec.ts
@@ -1,11 +1,5 @@
-import type {
-  Client,
-  EvaluationDetails,
-  JsonArray,
-  JsonObject,
-  JsonValue,
-  Provider,
-  ResolutionDetails} from '../src';
+import type { TrackingEventDetails } from '@openfeature/core';
+import type { Client, EvaluationDetails, JsonArray, JsonObject, JsonValue, Provider, ResolutionDetails } from '../src';
 import {
   ErrorCode,
   FlagNotFoundError,
@@ -62,6 +56,10 @@ const MOCK_PROVIDER: Provider = {
     return Promise.resolve(undefined);
   },
 
+  track: jest.fn((): void => {
+    return;
+  }),
+
   resolveNumberEvaluation: jest.fn((): ResolutionDetails<number> => {
     return {
       value: NUMBER_VALUE,
@@ -399,7 +397,7 @@ describe('OpenFeatureClient', () => {
       },
       initialize: () => {
         return Promise.resolve();
-      }
+      },
     } as unknown as Provider;
     it('status must be READY if init resolves', async () => {
       await OpenFeature.setProviderAndWait('1.7.1, 1.7.3', initProvider);
@@ -415,7 +413,7 @@ describe('OpenFeatureClient', () => {
       },
       initialize: async () => {
         return Promise.reject(new GeneralError());
-      }
+      },
     } as unknown as Provider;
     it('status must be ERROR if init rejects', async () => {
       await expect(OpenFeature.setProviderAndWait('1.7.4', errorProvider)).rejects.toThrow();
@@ -431,7 +429,7 @@ describe('OpenFeatureClient', () => {
       },
       initialize: () => {
         return Promise.reject(new ProviderFatalError());
-      }
+      },
     } as unknown as Provider;
     it('must short circuit and return PROVIDER_FATAL code if provider FATAL', async () => {
       await expect(OpenFeature.setProviderAndWait('1.7.5, 1.7.6, 1.7.8', fatalProvider)).rejects.toThrow();
@@ -454,7 +452,7 @@ describe('OpenFeatureClient', () => {
         return new Promise(() => {
           return; // promise never resolves
         });
-      }
+      },
     } as unknown as Provider;
     it('must short circuit and return PROVIDER_NOT_READY code if provider NOT_READY', async () => {
       OpenFeature.setProviderAndWait('1.7.7', neverReadyProvider).catch(() => {
@@ -637,4 +635,52 @@ describe('OpenFeatureClient', () => {
       expect(OpenFeature.getClient().providerStatus).toEqual(ProviderStatus.READY);
     });
   });
+
+  describe('tracking', () => {
+    describe('Requirement 2.7.1, Requirement 6.1.2.1', () => {
+      const eventName = 'test-tracking-event';
+      const trackingValue = 1234;
+      const trackingDetails: TrackingEventDetails = {
+        value: trackingValue,
+      };
+      const contextKey = 'key';
+      const contextValue = 'val';
+
+      it('should no-op and not throw if tracking not defined on provider', async () => {
+        await OpenFeature.setProviderAndWait({ ...MOCK_PROVIDER, track: undefined });
+        const client = OpenFeature.getClient();
+
+        expect(() => {
+          client.track(eventName, trackingDetails);
+        }).not.toThrow();
+      });
+
+      it('should no-op and not throw if provider throws', async () => {
+        await OpenFeature.setProviderAndWait({
+          ...MOCK_PROVIDER,
+          track: () => {
+            throw new Error('fake error');
+          },
+        });
+        const client = OpenFeature.getClient();
+
+        expect(() => {
+          client.track(eventName, trackingDetails);
+        }).not.toThrow();
+      });
+
+      it('should call provider with correct context', async () => {
+        await OpenFeature.setProviderAndWait({ ...MOCK_PROVIDER });
+        await OpenFeature.setContext({ [contextKey]: contextValue });
+        const client = OpenFeature.getClient();
+        client.track(eventName, trackingDetails);
+
+        expect(MOCK_PROVIDER.track).toHaveBeenCalledWith(
+          eventName,
+          expect.objectContaining({ [contextKey]: contextValue }),
+          expect.objectContaining({ value: trackingValue }),
+        );
+      });
+    });
+  });
 });