Skip to content

Commit dcdb631

Browse files
committed
feat: tracking
The `tracking API` enables the association of feature flag evaluations with subsequent actions or application states, in order to facilitate experimentation and analysis of the impact of feature flags on business objectives. Signed-off-by: Todd Baert <[email protected]>
1 parent def3fe8 commit dcdb631

File tree

26 files changed

+399
-70
lines changed

26 files changed

+399
-70
lines changed

packages/react/src/context/use-context-mutator.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export type ContextMutationOptions = {
1515

1616
export type ContextMutation = {
1717
/**
18-
* A function to set the desired context (see: {@link ContextMutationOptions} for details).
18+
* Context-aware function to set the desired context (see: {@link ContextMutationOptions} for details).
1919
* There's generally no need to await the result of this function; flag evaluation hooks will re-render when the context is updated.
2020
* This promise never rejects.
2121
* @param updatedContext
@@ -25,10 +25,10 @@ export type ContextMutation = {
2525
};
2626

2727
/**
28-
* Get function(s) for mutating the evaluation context associated with this domain, or the default context if `defaultContext: true`.
28+
* Get context-aware tracking function(s) for mutating the evaluation context associated with this domain, or the default context if `defaultContext: true`.
2929
* See the {@link https://openfeature.dev/docs/reference/technologies/client/web/#targeting-and-context|documentation} for more information.
3030
* @param {ContextMutationOptions} options options for the generated function
31-
* @returns {ContextMutation} function(s) to mutate context
31+
* @returns {ContextMutation} context-aware function(s) to mutate evaluation context
3232
*/
3333
export function useContextMutator(options: ContextMutationOptions = { defaultContext: false }): ContextMutation {
3434
const { domain } = useContext(Context) || {};

packages/react/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ export * from './evaluation';
22
export * from './query';
33
export * from './provider';
44
export * from './context';
5+
export * from './tracking';
56
// re-export the web-sdk so consumers can access that API from the react-sdk
67
export * from '@openfeature/web-sdk';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './use-track';
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { TrackingEventDetails } from '@openfeature/web-sdk';
2+
import { useCallback } from 'react';
3+
import { useOpenFeatureClient } from '../provider';
4+
5+
export type Track = {
6+
/**
7+
* Context-aware tracking function for the parent `<OpenFeatureProvider/>`.
8+
* Track a user action or application state, usually representing a business objective or outcome.
9+
* @param trackingEventName an identifier for the event
10+
* @param trackingEventDetails the details of the tracking event
11+
*/
12+
track: (trackingEventName: string, trackingEventDetails?: TrackingEventDetails) => void;
13+
};
14+
15+
/**
16+
* Get a context-aware tracking function.
17+
* @returns {Track} context-aware tracking
18+
*/
19+
export function useTrack(): Track {
20+
const client = useOpenFeatureClient();
21+
22+
const track = useCallback((trackingEventName: string, trackingEventDetails?: TrackingEventDetails) => {
23+
client.track(trackingEventName, trackingEventDetails);
24+
}, []);
25+
26+
return {
27+
track,
28+
};
29+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { jest } from '@jest/globals';
2+
import '@testing-library/jest-dom'; // see: https://testing-library.com/docs/react-testing-library/setup
3+
import { render } from '@testing-library/react';
4+
import * as React from 'react';
5+
import type { Provider, TrackingEventDetails } from '../src';
6+
import {
7+
OpenFeature,
8+
OpenFeatureProvider,
9+
useTrack
10+
} from '../src';
11+
12+
describe('tracking', () => {
13+
14+
const eventName = 'test-tracking-event';
15+
const trackingValue = 1234;
16+
const trackingDetails: TrackingEventDetails = {
17+
value: trackingValue,
18+
};
19+
const domain = 'someDomain';
20+
21+
const mockProvider = () => {
22+
const mockProvider: Provider = {
23+
metadata: {
24+
name: 'mock',
25+
},
26+
27+
track: jest.fn((): void => {
28+
return;
29+
}),
30+
} as unknown as Provider;
31+
32+
return mockProvider;
33+
};
34+
35+
describe('no domain', () => {
36+
it('should call default provider', async () => {
37+
38+
const provider = mockProvider();
39+
await OpenFeature.setProviderAndWait(provider);
40+
41+
function Component() {
42+
const { track } = useTrack();
43+
track(eventName, trackingDetails);
44+
45+
return <div></div>;
46+
}
47+
48+
render(
49+
<OpenFeatureProvider suspend={false} >
50+
<Component></Component>
51+
</OpenFeatureProvider>,
52+
);
53+
54+
expect(provider.track).toHaveBeenCalledWith(
55+
eventName,
56+
expect.anything(),
57+
expect.objectContaining({ value: trackingValue }),
58+
);
59+
});
60+
});
61+
62+
describe('domain set', () => {
63+
it('should call provider for domain', async () => {
64+
65+
const domainProvider = mockProvider();
66+
await OpenFeature.setProviderAndWait(domain, domainProvider);
67+
68+
function Component() {
69+
const { track } = useTrack();
70+
track(eventName, trackingDetails);
71+
72+
return <div></div>;
73+
}
74+
75+
render(
76+
<OpenFeatureProvider domain={domain} suspend={false} >
77+
<Component></Component>
78+
</OpenFeatureProvider>,
79+
);
80+
81+
expect(domainProvider.track).toHaveBeenCalledWith(
82+
eventName,
83+
expect.anything(),
84+
expect.objectContaining({ value: trackingValue }),
85+
);
86+
});
87+
});
88+
});

packages/server/src/client/client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ import type {
88
import type { Features } from '../evaluation';
99
import type { ProviderStatus } from '../provider';
1010
import type { ProviderEvents } from '../events';
11+
import type { Tracking } from '../tracking';
1112

1213
export interface Client
1314
extends EvaluationLifeCycle<Client>,
1415
Features,
1516
ManageContext<Client>,
1617
ManageLogger<Client>,
18+
Tracking,
1719
Eventing<ProviderEvents> {
1820
readonly metadata: ClientMetadata;
1921
/**

packages/server/src/client/internal/open-feature-client.ts

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
HookContext,
99
JsonValue,
1010
Logger,
11+
TrackingEventDetails,
1112
OpenFeatureError,
1213
ResolutionDetails} from '@openfeature/core';
1314
import {
@@ -223,6 +224,19 @@ export class OpenFeatureClient implements Client {
223224
return this.evaluate<T>(flagKey, this._provider.resolveObjectEvaluation, defaultValue, 'object', context, options);
224225
}
225226

227+
track(occurrenceKey: string, context: EvaluationContext, occurrenceDetails: TrackingEventDetails): void {
228+
229+
this.shortCircuitIfNotReady();
230+
231+
const mergedContext = this.mergeContexts(context);
232+
233+
if (typeof this._provider.track === 'function') {
234+
return this._provider.track?.(occurrenceKey, mergedContext, occurrenceDetails);
235+
} else {
236+
this._logger.debug('Provider does not implement track function: will no-op.');
237+
}
238+
}
239+
226240
private async evaluate<T extends FlagValue>(
227241
flagKey: string,
228242
resolver: (
@@ -246,13 +260,7 @@ export class OpenFeatureClient implements Client {
246260
];
247261
const allHooksReversed = [...allHooks].reverse();
248262

249-
// merge global and client contexts
250-
const mergedContext = {
251-
...OpenFeature.getContext(),
252-
...OpenFeature.getTransactionContext(),
253-
...this._context,
254-
...invocationContext,
255-
};
263+
const mergedContext = this.mergeContexts(invocationContext);
256264

257265
// this reference cannot change during the course of evaluation
258266
// it may be used as a key in WeakMaps
@@ -269,12 +277,7 @@ export class OpenFeatureClient implements Client {
269277
try {
270278
const frozenContext = await this.beforeHooks(allHooks, hookContext, options);
271279

272-
// short circuit evaluation entirely if provider is in a bad state
273-
if (this.providerStatus === ProviderStatus.NOT_READY) {
274-
throw new ProviderNotReadyError('provider has not yet initialized');
275-
} else if (this.providerStatus === ProviderStatus.FATAL) {
276-
throw new ProviderFatalError('provider is in an irrecoverable error state');
277-
}
280+
this.shortCircuitIfNotReady();
278281

279282
// run the referenced resolver, binding the provider.
280283
const resolution = await resolver.call(this._provider, flagKey, defaultValue, frozenContext, this._logger);
@@ -380,4 +383,23 @@ export class OpenFeatureClient implements Client {
380383
private get _logger() {
381384
return this._clientLogger || this.globalLogger();
382385
}
386+
387+
private mergeContexts(invocationContext: EvaluationContext) {
388+
// merge global and client contexts
389+
return {
390+
...OpenFeature.getContext(),
391+
...OpenFeature.getTransactionContext(),
392+
...this._context,
393+
...invocationContext,
394+
};
395+
}
396+
397+
private shortCircuitIfNotReady() {
398+
// short circuit evaluation entirely if provider is in a bad state
399+
if (this.providerStatus === ProviderStatus.NOT_READY) {
400+
throw new ProviderNotReadyError('provider has not yet initialized');
401+
} else if (this.providerStatus === ProviderStatus.FATAL) {
402+
throw new ProviderFatalError('provider is in an irrecoverable error state');
403+
}
404+
}
383405
}

packages/server/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ export * from './open-feature';
55
export * from './transaction-context';
66
export * from './events';
77
export * from './hooks';
8+
export * from './tracking';
89
export * from '@openfeature/core';

packages/server/src/provider/provider.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
1-
import type { CommonProvider, EvaluationContext, JsonValue, Logger, ResolutionDetails} from '@openfeature/core';
2-
import { ServerProviderStatus } from '@openfeature/core';
1+
import type {
2+
CommonProvider,
3+
EvaluationContext,
4+
JsonValue,
5+
Logger,
6+
TrackingEventDetails,
7+
ResolutionDetails} from '@openfeature/core';
8+
import {
9+
ServerProviderStatus,
10+
} from '@openfeature/core';
311
import type { Hook } from '../hooks';
412

513
export { ServerProviderStatus as ProviderStatus };
@@ -58,4 +66,12 @@ export interface Provider extends CommonProvider<ServerProviderStatus> {
5866
context: EvaluationContext,
5967
logger: Logger,
6068
): Promise<ResolutionDetails<T>>;
69+
70+
/**
71+
* Track a user action or application state, usually representing a business objective or outcome.
72+
* @param trackingEventName
73+
* @param context
74+
* @param trackingEventDetails
75+
*/
76+
track?(trackingEventName: string, context?: EvaluationContext, trackingEventDetails?: TrackingEventDetails): void;
6177
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './tracking';

0 commit comments

Comments
 (0)