Skip to content

Commit

Permalink
feat: implement tracking as per spec (#1020)
Browse files Browse the repository at this point in the history
📣 This was a draft for a while, but is now ready for review! 📣

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: #1033
Fixes: #1034

---------

Signed-off-by: Todd Baert <[email protected]>
  • Loading branch information
toddbaert authored Oct 29, 2024
1 parent 7f9001e commit 80f182e
Show file tree
Hide file tree
Showing 30 changed files with 445 additions and 77 deletions.
6 changes: 3 additions & 3 deletions packages/react/src/context/use-context-mutator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) || {};
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
1 change: 1 addition & 0 deletions packages/react/src/tracking/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './use-track';
29 changes: 29 additions & 0 deletions packages/react/src/tracking/use-track.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
88 changes: 88 additions & 0 deletions packages/react/test/tracking.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 }),
);
});
});
});
2 changes: 2 additions & 0 deletions packages/server/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
57 changes: 42 additions & 15 deletions packages/server/src/client/internal/open-feature-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
HookContext,
JsonValue,
Logger,
TrackingEventDetails,
OpenFeatureError,
ResolutionDetails} from '@openfeature/core';
import {
Expand All @@ -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';
Expand Down Expand Up @@ -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 = {},
Expand Down Expand Up @@ -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: (
Expand All @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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');
}
}
}
1 change: 1 addition & 0 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
3 changes: 3 additions & 0 deletions packages/server/src/open-feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 9 additions & 2 deletions packages/server/src/provider/provider.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down
1 change: 1 addition & 0 deletions packages/server/src/tracking/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './tracking';
12 changes: 12 additions & 0 deletions packages/server/src/tracking/tracking.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading

0 comments on commit 80f182e

Please sign in to comment.