Skip to content
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

feat: implement tracking as per spec #1020

Merged
merged 7 commits into from
Oct 29, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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 { TrackingEventDetails } from '@openfeature/web-sdk';
import { useCallback } from 'react';
import { useOpenFeatureClient } from '../provider';

export type Track = {
beeme1mr marked this conversation as resolved.
Show resolved Hide resolved
/**
* 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: (trackingEventName: string, trackingEventDetails?: TrackingEventDetails) => void;
};

/**
* 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
52 changes: 39 additions & 13 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 Down Expand Up @@ -223,6 +224,23 @@ 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();

const mergedContext = this.mergeContexts(context);
Object.freeze(mergedContext);

if (typeof this._provider.track === 'function') {
return this._provider.track?.(occurrenceKey, mergedContext, 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 @@ -246,13 +264,7 @@ export class OpenFeatureClient implements Client {
];
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 +281,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 +387,23 @@ export class OpenFeatureClient implements Client {
private get _logger() {
return this._clientLogger || this.globalLogger();
}

private mergeContexts(invocationContext: EvaluationContext) {
// merge global and client contexts
return {
...OpenFeature.getContext(),
...OpenFeature.getTransactionContext(),
toddbaert marked this conversation as resolved.
Show resolved Hide resolved
...this._context,
...invocationContext,
};
}

private shortCircuitIfNotReady() {
beeme1mr marked this conversation as resolved.
Show resolved Hide resolved
// 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';
20 changes: 18 additions & 2 deletions packages/server/src/provider/provider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import type { CommonProvider, EvaluationContext, JsonValue, Logger, ResolutionDetails} from '@openfeature/core';
import { ServerProviderStatus } from '@openfeature/core';
import type {
CommonProvider,
EvaluationContext,
JsonValue,
Logger,
TrackingEventDetails,
ResolutionDetails} from '@openfeature/core';
import {
ServerProviderStatus,
} from '@openfeature/core';
import type { Hook } from '../hooks';

export { ServerProviderStatus as ProviderStatus };
Expand Down Expand Up @@ -58,4 +66,12 @@ export interface Provider extends CommonProvider<ServerProviderStatus> {
context: EvaluationContext,
logger: Logger,
): Promise<ResolutionDetails<T>>;

/**
* 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;
}
toddbaert marked this conversation as resolved.
Show resolved Hide resolved
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