Skip to content

feat: implement tracking as per spec #1020

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Oct 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading