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 }), + ); + }); + }); + }); });