Skip to content

Commit 80f182e

Browse files
authored
feat: implement tracking as per spec (#1020)
📣 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]>
1 parent 7f9001e commit 80f182e

30 files changed

+445
-77
lines changed

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

+3-3
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

+1
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';

packages/react/src/tracking/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './use-track';
+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { Tracking, 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: Tracking['track'];
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+
}

packages/react/test/tracking.spec.tsx

+88
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

+2
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

+42-15
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 {
@@ -23,7 +24,6 @@ import type { FlagEvaluationOptions } from '../../evaluation';
2324
import type { ProviderEvents } from '../../events';
2425
import type { InternalEventEmitter } from '../../events/internal/internal-event-emitter';
2526
import type { Hook } from '../../hooks';
26-
import { OpenFeature } from '../../open-feature';
2727
import type { Provider} from '../../provider';
2828
import { ProviderStatus } from '../../provider';
2929
import type { Client } from './../client';
@@ -53,6 +53,9 @@ export class OpenFeatureClient implements Client {
5353
private readonly providerAccessor: () => Provider,
5454
private readonly providerStatusAccessor: () => ProviderStatus,
5555
private readonly emitterAccessor: () => InternalEventEmitter,
56+
private readonly apiContextAccessor: () => EvaluationContext,
57+
private readonly apiHooksAccessor: () => Hook[],
58+
private readonly transactionContextAccessor: () => EvaluationContext,
5659
private readonly globalLogger: () => Logger,
5760
private readonly options: OpenFeatureClientOptions,
5861
context: EvaluationContext = {},
@@ -223,6 +226,22 @@ export class OpenFeatureClient implements Client {
223226
return this.evaluate<T>(flagKey, this._provider.resolveObjectEvaluation, defaultValue, 'object', context, options);
224227
}
225228

229+
track(occurrenceKey: string, context: EvaluationContext, occurrenceDetails: TrackingEventDetails): void {
230+
try {
231+
this.shortCircuitIfNotReady();
232+
233+
if (typeof this._provider.track === 'function') {
234+
// freeze the merged context
235+
const frozenContext = Object.freeze(this.mergeContexts(context));
236+
return this._provider.track?.(occurrenceKey, frozenContext, occurrenceDetails);
237+
} else {
238+
this._logger.debug('Provider does not support the track function; will no-op.');
239+
}
240+
} catch (err) {
241+
this._logger.debug('Error recording tracking event.', err);
242+
}
243+
}
244+
226245
private async evaluate<T extends FlagValue>(
227246
flagKey: string,
228247
resolver: (
@@ -239,20 +258,14 @@ export class OpenFeatureClient implements Client {
239258
// merge global, client, and evaluation context
240259

241260
const allHooks = [
242-
...OpenFeature.getHooks(),
261+
...this.apiHooksAccessor(),
243262
...this.getHooks(),
244263
...(options.hooks || []),
245264
...(this._provider.hooks || []),
246265
];
247266
const allHooksReversed = [...allHooks].reverse();
248267

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

257270
// this reference cannot change during the course of evaluation
258271
// it may be used as a key in WeakMaps
@@ -269,12 +282,7 @@ export class OpenFeatureClient implements Client {
269282
try {
270283
const frozenContext = await this.beforeHooks(allHooks, hookContext, options);
271284

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-
}
285+
this.shortCircuitIfNotReady();
278286

279287
// run the referenced resolver, binding the provider.
280288
const resolution = await resolver.call(this._provider, flagKey, defaultValue, frozenContext, this._logger);
@@ -380,4 +388,23 @@ export class OpenFeatureClient implements Client {
380388
private get _logger() {
381389
return this._clientLogger || this.globalLogger();
382390
}
391+
392+
private mergeContexts(invocationContext: EvaluationContext) {
393+
// merge global and client contexts
394+
return {
395+
...this.apiContextAccessor(),
396+
...this.transactionContextAccessor(),
397+
...this._context,
398+
...invocationContext,
399+
};
400+
}
401+
402+
private shortCircuitIfNotReady() {
403+
// short circuit evaluation entirely if provider is in a bad state
404+
if (this.providerStatus === ProviderStatus.NOT_READY) {
405+
throw new ProviderNotReadyError('provider has not yet initialized');
406+
} else if (this.providerStatus === ProviderStatus.FATAL) {
407+
throw new ProviderFatalError('provider is in an irrecoverable error state');
408+
}
409+
}
383410
}

packages/server/src/index.ts

+1
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/open-feature.ts

+3
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,9 @@ export class OpenFeatureAPI
198198
() => this.getProviderForClient(domain),
199199
() => this.getProviderStatus(domain),
200200
() => this.buildAndCacheEventEmitterForClient(domain),
201+
() => this.getContext(),
202+
() => this.getHooks(),
203+
() => this.getTransactionContext(),
201204
() => this._logger,
202205
{ domain, version },
203206
context,

packages/server/src/provider/provider.ts

+9-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
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+
ResolutionDetails} from '@openfeature/core';
7+
import {
8+
ServerProviderStatus,
9+
} from '@openfeature/core';
310
import type { Hook } from '../hooks';
411

512
export { ServerProviderStatus as ProviderStatus };

packages/server/src/tracking/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './tracking';
+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { EvaluationContext, TrackingEventDetails } from '@openfeature/core';
2+
3+
export interface Tracking {
4+
5+
/**
6+
* Track a user action or application state, usually representing a business objective or outcome.
7+
* @param trackingEventName an identifier for the event
8+
* @param context the evaluation context
9+
* @param trackingEventDetails the details of the tracking event
10+
*/
11+
track(trackingEventName: string, context?: EvaluationContext, trackingEventDetails?: TrackingEventDetails): void;
12+
}

0 commit comments

Comments
 (0)