diff --git a/packages/react/src/context/use-context-mutator.ts b/packages/react/src/context/use-context-mutator.ts
index 420e923c6..2667f1865 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 6690ab35a..e2f902c52 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 000000000..8a639f15a
--- /dev/null
+++ b/packages/react/src/tracking/index.ts
@@ -0,0 +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 000000000..9fad805cd
--- /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 ``.
+ * 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/react/test/tracking.spec.tsx b/packages/react/test/tracking.spec.tsx
new file mode 100644
index 000000000..2b4779f65
--- /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
;
+ }
+
+ render(
+
+
+ ,
+ );
+
+ 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 ;
+ }
+
+ render(
+
+
+ ,
+ );
+
+ expect(domainProvider.track).toHaveBeenCalledWith(
+ eventName,
+ expect.anything(),
+ expect.objectContaining({ value: trackingValue }),
+ );
+ });
+ });
+});
diff --git a/packages/server/src/client/client.ts b/packages/server/src/client/client.ts
index 9efe1220d..0446246bc 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,
Features,
ManageContext,
ManageLogger,
+ Tracking,
Eventing {
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 5678c8082..46a5a75ba 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(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(
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 c229d16fb..249135cbf 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 995c08842..ae4b439f0 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 1c001a83e..4c97f1775 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 000000000..5794a1d40
--- /dev/null
+++ b/packages/server/src/tracking/index.ts
@@ -0,0 +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 000000000..b7ef17e9c
--- /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/server/test/client.spec.ts b/packages/server/test/client.spec.ts
index e66048484..69dc41fa5 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> => {
return Promise.resolve({
value: BOOLEAN_VALUE,
@@ -64,6 +71,7 @@ const MOCK_PROVIDER: Provider = {
reason: REASON,
});
}),
+
resolveStringEvaluation: jest.fn((): Promise> => {
return Promise.resolve({
value: STRING_VALUE,
@@ -71,6 +79,7 @@ const MOCK_PROVIDER: Provider = {
reason: REASON,
}) as Promise>;
}) as () => Promise>,
+
resolveNumberEvaluation: jest.fn((): Promise> => {
return Promise.resolve({
value: NUMBER_VALUE,
@@ -78,6 +87,7 @@ const MOCK_PROVIDER: Provider = {
reason: REASON,
});
}),
+
resolveObjectEvaluation: jest.fn((): Promise> => {
const details = Promise.resolve>({
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/shared/src/evaluation/context.ts b/packages/shared/src/evaluation/context.ts
index 0412fd2a5..db4f9597c 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 c900b40f2..895a1ddcc 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 8a85f4142..42c1504a0 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 06f869081..e964f1b8e 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;
+
+ /**
+ * 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 000000000..41426f0b3
--- /dev/null
+++ b/packages/shared/src/tracking/index.ts
@@ -0,0 +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 000000000..cfe008bd4
--- /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;
diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts
index 69cf2f63f..6c96c078a 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 000000000..7edbdd937
--- /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 2cb14b491..be3f058ab 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, Features, ManageLogger, Eventing {
+export interface Client
+ extends EvaluationLifeCycle,
+ Features,
+ ManageLogger,
+ Eventing,
+ 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 600a6d717..d7189226d 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(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(
flagKey: string,
resolver: (flagKey: string, defaultValue: T, context: EvaluationContext, logger: Logger) => ResolutionDetails,
@@ -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 355cd9ce1..fa481b733 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 27c1f5610..9c35a31a4 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 26690a56b..3af11cc44 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 03c6d8cb8..127854089 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 000000000..5794a1d40
--- /dev/null
+++ b/packages/web/src/tracking/index.ts
@@ -0,0 +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 000000000..b37c45b96
--- /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;
+}
diff --git a/packages/web/test/client.spec.ts b/packages/web/test/client.spec.ts
index 3fd93162a..381dd9b25 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 => {
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 }),
+ );
+ });
+ });
+ });
});