diff --git a/libs/providers/go-feature-flag-web/src/lib/collector-manager.ts b/libs/providers/go-feature-flag-web/src/lib/collector-manager.ts new file mode 100644 index 000000000..59e6ea5a7 --- /dev/null +++ b/libs/providers/go-feature-flag-web/src/lib/collector-manager.ts @@ -0,0 +1,71 @@ +import { Logger } from '@openfeature/web-sdk'; +import { ExporterMetadataValue, FeatureEvent, GoFeatureFlagWebProviderOptions, TrackingEvent } from './model'; +import { GoffApiController } from './controller/goff-api'; +import { CollectorError } from './errors/collector-error'; +import { copy } from 'copy-anything'; + +type Timer = ReturnType<typeof setInterval>; + +export class CollectorManager { + // bgSchedulerId contains the id of the setInterval that is running. + private bgScheduler?: Timer; + // dataCollectorBuffer contains all the FeatureEvents that we need to send to the relay-proxy for data collection. + private dataCollectorBuffer?: Array<FeatureEvent<any> | TrackingEvent>; + // dataFlushInterval interval time (in millisecond) we use to call the relay proxy to collect data. + private readonly dataFlushInterval: number; + // logger is the Open Feature logger to use + private logger?: Logger; + // dataCollectorMetadata are the metadata used when calling the data collector endpoint + private readonly dataCollectorMetadata: Record<string, ExporterMetadataValue>; + + private readonly goffApiController: GoffApiController; + + constructor(options: GoFeatureFlagWebProviderOptions, logger?: Logger) { + this.dataFlushInterval = options.dataFlushInterval || 1000 * 60; + this.logger = logger; + this.goffApiController = new GoffApiController(options); + + this.dataCollectorMetadata = { + provider: 'web', + openfeature: true, + ...options.exporterMetadata, + }; + } + + init() { + this.bgScheduler = setInterval(async () => await this.callGoffDataCollection(), this.dataFlushInterval); + this.dataCollectorBuffer = []; + } + + async close() { + clearInterval(this.bgScheduler); + // We call the data collector with what is still in the buffer. + await this.callGoffDataCollection(); + } + + add(event: FeatureEvent<any> | TrackingEvent) { + if (this.dataCollectorBuffer) { + this.dataCollectorBuffer.push(event); + } + } + + /** + * callGoffDataCollection is a function called periodically to send the usage of the flag to the + * central service in charge of collecting the data. + */ + async callGoffDataCollection() { + const dataToSend = copy(this.dataCollectorBuffer) || []; + this.dataCollectorBuffer = []; + try { + await this.goffApiController.collectData(dataToSend, this.dataCollectorMetadata); + } catch (e) { + if (!(e instanceof CollectorError)) { + throw e; + } + this.logger?.error(e); + // if we have an issue calling the collector, we put the data back in the buffer + this.dataCollectorBuffer = [...this.dataCollectorBuffer, ...dataToSend]; + return; + } + } +} diff --git a/libs/providers/go-feature-flag-web/src/lib/controller/goff-api.ts b/libs/providers/go-feature-flag-web/src/lib/controller/goff-api.ts index d1ce4f922..ed70bbf9b 100644 --- a/libs/providers/go-feature-flag-web/src/lib/controller/goff-api.ts +++ b/libs/providers/go-feature-flag-web/src/lib/controller/goff-api.ts @@ -1,4 +1,10 @@ -import { DataCollectorRequest, ExporterMetadataValue, FeatureEvent, GoFeatureFlagWebProviderOptions } from '../model'; +import { + DataCollectorRequest, + ExporterMetadataValue, + FeatureEvent, + GoFeatureFlagWebProviderOptions, + TrackingEvent, +} from '../model'; import { CollectorError } from '../errors/collector-error'; export class GoffApiController { @@ -15,7 +21,10 @@ export class GoffApiController { this.options = options; } - async collectData(events: FeatureEvent<any>[], dataCollectorMetadata: Record<string, ExporterMetadataValue>) { + async collectData( + events: Array<FeatureEvent<any> | TrackingEvent>, + dataCollectorMetadata: Record<string, ExporterMetadataValue>, + ) { if (events?.length === 0) { return; } diff --git a/libs/providers/go-feature-flag-web/src/lib/data-collector-hook.ts b/libs/providers/go-feature-flag-web/src/lib/data-collector-hook.ts index dccb7dfb5..8ec5f9704 100644 --- a/libs/providers/go-feature-flag-web/src/lib/data-collector-hook.ts +++ b/libs/providers/go-feature-flag-web/src/lib/data-collector-hook.ts @@ -1,65 +1,14 @@ -import { EvaluationDetails, FlagValue, Hook, HookContext, Logger } from '@openfeature/web-sdk'; -import { ExporterMetadataValue, FeatureEvent, GoFeatureFlagWebProviderOptions } from './model'; -import { copy } from 'copy-anything'; -import { CollectorError } from './errors/collector-error'; -import { GoffApiController } from './controller/goff-api'; +import { EvaluationDetails, FlagValue, Hook, HookContext } from '@openfeature/web-sdk'; +import { CollectorManager } from './collector-manager'; const defaultTargetingKey = 'undefined-targetingKey'; type Timer = ReturnType<typeof setInterval>; export class GoFeatureFlagDataCollectorHook implements Hook { - // bgSchedulerId contains the id of the setInterval that is running. - private bgScheduler?: Timer; - // dataCollectorBuffer contains all the FeatureEvents that we need to send to the relay-proxy for data collection. - private dataCollectorBuffer?: FeatureEvent<any>[]; - // dataFlushInterval interval time (in millisecond) we use to call the relay proxy to collect data. - private readonly dataFlushInterval: number; - // dataCollectorMetadata are the metadata used when calling the data collector endpoint - private readonly dataCollectorMetadata: Record<string, ExporterMetadataValue>; - private readonly goffApiController: GoffApiController; - // logger is the Open Feature logger to use - private logger?: Logger; + private collectorManagger?: CollectorManager; - constructor(options: GoFeatureFlagWebProviderOptions, logger?: Logger) { - this.dataFlushInterval = options.dataFlushInterval || 1000 * 60; - this.logger = logger; - this.goffApiController = new GoffApiController(options); - this.dataCollectorMetadata = { - provider: 'web', - openfeature: true, - ...options.exporterMetadata, - }; - } - - init() { - this.bgScheduler = setInterval(async () => await this.callGoffDataCollection(), this.dataFlushInterval); - this.dataCollectorBuffer = []; - } - - async close() { - clearInterval(this.bgScheduler); - // We call the data collector with what is still in the buffer. - await this.callGoffDataCollection(); - } - - /** - * callGoffDataCollection is a function called periodically to send the usage of the flag to the - * central service in charge of collecting the data. - */ - async callGoffDataCollection() { - const dataToSend = copy(this.dataCollectorBuffer) || []; - this.dataCollectorBuffer = []; - try { - await this.goffApiController.collectData(dataToSend, this.dataCollectorMetadata); - } catch (e) { - if (!(e instanceof CollectorError)) { - throw e; - } - this.logger?.error(e); - // if we have an issue calling the collector, we put the data back in the buffer - this.dataCollectorBuffer = [...this.dataCollectorBuffer, ...dataToSend]; - return; - } + constructor(collectorManager: CollectorManager) { + this.collectorManagger = collectorManager; } after(hookContext: HookContext, evaluationDetails: EvaluationDetails<FlagValue>) { @@ -74,7 +23,7 @@ export class GoFeatureFlagDataCollectorHook implements Hook { userKey: hookContext.context.targetingKey || defaultTargetingKey, source: 'PROVIDER_CACHE', }; - this.dataCollectorBuffer?.push(event); + this.collectorManagger?.add(event); } error(hookContext: HookContext) { @@ -89,6 +38,6 @@ export class GoFeatureFlagDataCollectorHook implements Hook { userKey: hookContext.context.targetingKey || defaultTargetingKey, source: 'PROVIDER_CACHE', }; - this.dataCollectorBuffer?.push(event); + this.collectorManagger?.add(event); } } diff --git a/libs/providers/go-feature-flag-web/src/lib/go-feature-flag-web-provider.spec.ts b/libs/providers/go-feature-flag-web/src/lib/go-feature-flag-web-provider.spec.ts index 2ed757c18..03b7de332 100644 --- a/libs/providers/go-feature-flag-web/src/lib/go-feature-flag-web-provider.spec.ts +++ b/libs/providers/go-feature-flag-web/src/lib/go-feature-flag-web-provider.spec.ts @@ -10,7 +10,7 @@ import { } from '@openfeature/web-sdk'; import WS from 'jest-websocket-mock'; import TestLogger from './test-logger'; -import { DataCollectorRequest, GOFeatureFlagWebsocketResponse } from './model'; +import { DataCollectorRequest, GOFeatureFlagWebsocketResponse, TrackingEvent } from './model'; import fetchMock from 'fetch-mock-jest'; describe('GoFeatureFlagWebProvider', () => { @@ -456,156 +456,204 @@ describe('GoFeatureFlagWebProvider', () => { }); describe('data collector testing', () => { - it('should call the data collector when closing Open Feature', async () => { - const clientName = expect.getState().currentTestName ?? 'test-provider'; - await OpenFeature.setContext(defaultContext); - const p = new GoFeatureFlagWebProvider( - { - endpoint: endpoint, - apiTimeout: 1000, - maxRetries: 1, - dataFlushInterval: 10000, - apiKey: 'toto', - }, - logger, - ); - - await OpenFeature.setProviderAndWait(clientName, p); - const client = OpenFeature.getClient(clientName); - await websocketMockServer.connected; - await new Promise((resolve) => setTimeout(resolve, 5)); - - client.getBooleanDetails('bool_flag', false); - client.getBooleanDetails('bool_flag', false); - - await OpenFeature.close(); - - expect(fetchMock.calls(dataCollectorEndpoint).length).toBe(1); - expect(fetchMock.lastOptions(dataCollectorEndpoint)?.headers).toEqual({ - 'Content-Type': 'application/json', - Accept: 'application/json', - Authorization: 'Bearer toto', + describe('tracking event', () => { + it('should send tracking event to the data collector', async () => { + const clientName = expect.getState().currentTestName ?? 'test-provider'; + await OpenFeature.setContext(defaultContext); + const p = new GoFeatureFlagWebProvider( + { + endpoint: endpoint, + apiTimeout: 1000, + maxRetries: 1, + dataFlushInterval: 10000, + }, + logger, + ); + + await OpenFeature.setProviderAndWait(clientName, p); + const client = OpenFeature.getClient(clientName); + await websocketMockServer.connected; + await new Promise((resolve) => setTimeout(resolve, 5)); + + client.getBooleanDetails('bool_flag', false); + client.getBooleanDetails('bool_flag', false); + client.track('event-key-123abc', { value: 99.77, currency: 'USD' }); + + await OpenFeature.close(); + + expect(fetchMock.calls(dataCollectorEndpoint).length).toBe(1); + const reqBody = fetchMock.lastOptions(dataCollectorEndpoint)?.body; + const parsedBody = JSON.parse(reqBody as never) as DataCollectorRequest<never>; + expect(parsedBody.events.length).toBe(3); + expect(parsedBody.events.filter((event) => event.kind === 'tracking').length).toBe(1); + expect(parsedBody.events.filter((event) => event.kind === 'feature').length).toBe(2); + + const trackingEvent = parsedBody.events.find((event) => event.kind === 'tracking'); + expect(trackingEvent).not.toBeUndefined(); + const c = trackingEvent as TrackingEvent; + expect(c.key).toEqual('event-key-123abc'); + expect(c.kind).toEqual('tracking'); + expect(c.contextKind).toEqual('user'); + expect(c.userKey).toEqual(defaultContext.targetingKey); + expect(c.creationDate).toBeGreaterThan(0); + expect(c.evaluationContext).toEqual(defaultContext); + expect(c.trackingEventDetails).toEqual({ value: 99.77, currency: 'USD' }); }); }); - it('should call the data collector when waiting more than the dataFlushInterval', async () => { - const clientName = expect.getState().currentTestName ?? 'test-provider'; - await OpenFeature.setContext(defaultContext); - const p = new GoFeatureFlagWebProvider( - { - endpoint: endpoint, - apiTimeout: 1000, - maxRetries: 1, - dataFlushInterval: 200, - }, - logger, - ); - - await OpenFeature.setProviderAndWait(clientName, p); - const client = OpenFeature.getClient(clientName); - await websocketMockServer.connected; - await new Promise((resolve) => setTimeout(resolve, 5)); - - client.getBooleanDetails('bool_flag', false); - client.getBooleanDetails('bool_flag', false); - - await new Promise((resolve) => setTimeout(resolve, 300)); - - expect(fetchMock.calls(dataCollectorEndpoint).length).toBe(1); - expect(fetchMock.lastOptions(dataCollectorEndpoint)?.headers).toEqual({ - 'Content-Type': 'application/json', - Accept: 'application/json', + describe('feature event', () => { + it('should call the data collector when closing Open Feature', async () => { + const clientName = expect.getState().currentTestName ?? 'test-provider'; + await OpenFeature.setContext(defaultContext); + const p = new GoFeatureFlagWebProvider( + { + endpoint: endpoint, + apiTimeout: 1000, + maxRetries: 1, + dataFlushInterval: 10000, + apiKey: 'toto', + }, + logger, + ); + + await OpenFeature.setProviderAndWait(clientName, p); + const client = OpenFeature.getClient(clientName); + await websocketMockServer.connected; + await new Promise((resolve) => setTimeout(resolve, 5)); + + client.getBooleanDetails('bool_flag', false); + client.getBooleanDetails('bool_flag', false); + + await OpenFeature.close(); + + expect(fetchMock.calls(dataCollectorEndpoint).length).toBe(1); + expect(fetchMock.lastOptions(dataCollectorEndpoint)?.headers).toEqual({ + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: 'Bearer toto', + }); }); - await OpenFeature.close(); - }); - it('should call the data collector multiple time while waiting dataFlushInterval time', async () => { - const clientName = expect.getState().currentTestName ?? 'test-provider'; - await OpenFeature.setContext(defaultContext); - const p = new GoFeatureFlagWebProvider( - { - endpoint: endpoint, - apiTimeout: 1000, - maxRetries: 1, - dataFlushInterval: 200, - }, - logger, - ); - await OpenFeature.setProviderAndWait(clientName, p); - const client = OpenFeature.getClient(clientName); - await websocketMockServer.connected; - await new Promise((resolve) => setTimeout(resolve, 5)); - client.getBooleanDetails('bool_flag', false); - client.getBooleanDetails('bool_flag', false); - await new Promise((resolve) => setTimeout(resolve, 250)); - client.getBooleanDetails('bool_flag', false); - client.getBooleanDetails('bool_flag', false); - await new Promise((resolve) => setTimeout(resolve, 300)); - - expect(fetchMock.calls(dataCollectorEndpoint).length).toBe(2); - await OpenFeature.close(); - }); - - it('should not call the data collector before the dataFlushInterval', async () => { - const clientName = expect.getState().currentTestName ?? 'test-provider'; - await OpenFeature.setContext(defaultContext); - const p = new GoFeatureFlagWebProvider( - { - endpoint: endpoint, - apiTimeout: 1000, - maxRetries: 1, - dataFlushInterval: 200, - }, - logger, - ); - - await OpenFeature.setProviderAndWait(clientName, p); - const client = OpenFeature.getClient(clientName); - await websocketMockServer.connected; - await new Promise((resolve) => setTimeout(resolve, 5)); - client.getBooleanDetails('bool_flag', false); - client.getBooleanDetails('bool_flag', false); - await new Promise((resolve) => setTimeout(resolve, 100)); - - expect(fetchMock.calls(dataCollectorEndpoint).length).toBe(0); - await OpenFeature.close(); - }); - - it('should have a log when data collector is not available', async () => { - const clientName = expect.getState().currentTestName ?? 'test-provider'; - fetchMock.post(dataCollectorEndpoint, 500, { overwriteRoutes: true }); - await OpenFeature.setContext(defaultContext); - const p = new GoFeatureFlagWebProvider( - { - endpoint: endpoint, - apiTimeout: 1000, - maxRetries: 1, - dataFlushInterval: 200, - }, - logger, - ); - - await OpenFeature.setProviderAndWait(clientName, p); - const client = OpenFeature.getClient(clientName); - await websocketMockServer.connected; - await new Promise((resolve) => setTimeout(resolve, 5)); - client.getBooleanDetails('bool_flag', false); - client.getBooleanDetails('bool_flag', false); - await new Promise((resolve) => setTimeout(resolve, 250)); - - fetchMock.post(dataCollectorEndpoint, 500, { overwriteRoutes: true }); + it('should call the data collector when waiting more than the dataFlushInterval', async () => { + const clientName = expect.getState().currentTestName ?? 'test-provider'; + await OpenFeature.setContext(defaultContext); + const p = new GoFeatureFlagWebProvider( + { + endpoint: endpoint, + apiTimeout: 1000, + maxRetries: 1, + dataFlushInterval: 200, + }, + logger, + ); + + await OpenFeature.setProviderAndWait(clientName, p); + const client = OpenFeature.getClient(clientName); + await websocketMockServer.connected; + await new Promise((resolve) => setTimeout(resolve, 5)); + + client.getBooleanDetails('bool_flag', false); + client.getBooleanDetails('bool_flag', false); + + await new Promise((resolve) => setTimeout(resolve, 300)); + + expect(fetchMock.calls(dataCollectorEndpoint).length).toBe(1); + expect(fetchMock.lastOptions(dataCollectorEndpoint)?.headers).toEqual({ + 'Content-Type': 'application/json', + Accept: 'application/json', + }); + await OpenFeature.close(); + }); + it('should call the data collector multiple time while waiting dataFlushInterval time', async () => { + const clientName = expect.getState().currentTestName ?? 'test-provider'; + await OpenFeature.setContext(defaultContext); + const p = new GoFeatureFlagWebProvider( + { + endpoint: endpoint, + apiTimeout: 1000, + maxRetries: 1, + dataFlushInterval: 200, + }, + logger, + ); + + await OpenFeature.setProviderAndWait(clientName, p); + const client = OpenFeature.getClient(clientName); + await websocketMockServer.connected; + await new Promise((resolve) => setTimeout(resolve, 5)); + client.getBooleanDetails('bool_flag', false); + client.getBooleanDetails('bool_flag', false); + await new Promise((resolve) => setTimeout(resolve, 250)); + client.getBooleanDetails('bool_flag', false); + client.getBooleanDetails('bool_flag', false); + await new Promise((resolve) => setTimeout(resolve, 300)); + + expect(fetchMock.calls(dataCollectorEndpoint).length).toBe(2); + await OpenFeature.close(); + }); - client.getBooleanDetails('bool_flag', false); - client.getBooleanDetails('bool_flag', false); - fetchMock.post(dataCollectorEndpoint, 200, { overwriteRoutes: true }); - await new Promise((resolve) => setTimeout(resolve, 250)); + it('should not call the data collector before the dataFlushInterval', async () => { + const clientName = expect.getState().currentTestName ?? 'test-provider'; + await OpenFeature.setContext(defaultContext); + const p = new GoFeatureFlagWebProvider( + { + endpoint: endpoint, + apiTimeout: 1000, + maxRetries: 1, + dataFlushInterval: 200, + }, + logger, + ); + + await OpenFeature.setProviderAndWait(clientName, p); + const client = OpenFeature.getClient(clientName); + await websocketMockServer.connected; + await new Promise((resolve) => setTimeout(resolve, 5)); + client.getBooleanDetails('bool_flag', false); + client.getBooleanDetails('bool_flag', false); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(fetchMock.calls(dataCollectorEndpoint).length).toBe(0); + await OpenFeature.close(); + }); - const lastBody = fetchMock.lastOptions(dataCollectorEndpoint)?.body; - const parsedBody = JSON.parse(lastBody as never); - expect(parsedBody['events'].length).toBe(4); - await OpenFeature.close(); + it('should have a log when data collector is not available', async () => { + const clientName = expect.getState().currentTestName ?? 'test-provider'; + fetchMock.post(dataCollectorEndpoint, 500, { overwriteRoutes: true }); + await OpenFeature.setContext(defaultContext); + const p = new GoFeatureFlagWebProvider( + { + endpoint: endpoint, + apiTimeout: 1000, + maxRetries: 1, + dataFlushInterval: 200, + }, + logger, + ); + + await OpenFeature.setProviderAndWait(clientName, p); + const client = OpenFeature.getClient(clientName); + await websocketMockServer.connected; + await new Promise((resolve) => setTimeout(resolve, 5)); + client.getBooleanDetails('bool_flag', false); + client.getBooleanDetails('bool_flag', false); + await new Promise((resolve) => setTimeout(resolve, 250)); + + fetchMock.post(dataCollectorEndpoint, 500, { overwriteRoutes: true }); + + client.getBooleanDetails('bool_flag', false); + client.getBooleanDetails('bool_flag', false); + fetchMock.post(dataCollectorEndpoint, 200, { overwriteRoutes: true }); + await new Promise((resolve) => setTimeout(resolve, 250)); + + const lastBody = fetchMock.lastOptions(dataCollectorEndpoint)?.body; + const parsedBody = JSON.parse(lastBody as never); + expect(parsedBody['events'].length).toBe(4); + await OpenFeature.close(); + }); }); }); + it('should resolve when WebSocket is open', async () => { const provider = new GoFeatureFlagWebProvider({ endpoint: 'http://localhost:1031', apiTimeout: 1000 }); await provider.initialize({ targetingKey: 'user-key' }); @@ -658,7 +706,13 @@ describe('GoFeatureFlagWebProvider', () => { expect(fetchMock.calls(dataCollectorEndpoint).length).toBe(1); const jsonBody = fetchMock.lastOptions(dataCollectorEndpoint)?.body; const body = JSON.parse(jsonBody as never) as DataCollectorRequest<never>; - expect(body.meta).toEqual({ browser: 'chrome', version: '1.0.0', score: 123, openfeature: true, provider: 'web' }); + expect(body.meta).toEqual({ + browser: 'chrome', + version: '1.0.0', + score: 123, + openfeature: true, + provider: 'web', + }); }); }); diff --git a/libs/providers/go-feature-flag-web/src/lib/go-feature-flag-web-provider.ts b/libs/providers/go-feature-flag-web/src/lib/go-feature-flag-web-provider.ts index 4e282d018..d6e9e08d1 100644 --- a/libs/providers/go-feature-flag-web/src/lib/go-feature-flag-web-provider.ts +++ b/libs/providers/go-feature-flag-web/src/lib/go-feature-flag-web-provider.ts @@ -10,6 +10,7 @@ import { ProviderEvents, ResolutionDetails, StandardResolutionReasons, + TrackingEventDetails, TypeMismatchError, } from '@openfeature/web-sdk'; import { @@ -18,10 +19,12 @@ import { GOFeatureFlagAllFlagsResponse, GoFeatureFlagWebProviderOptions, GOFeatureFlagWebsocketResponse, + TrackingEvent, } from './model'; import { transformContext } from './context-transformer'; import { FetchError } from './errors/fetch-error'; import { GoFeatureFlagDataCollectorHook } from './data-collector-hook'; +import { CollectorManager } from './collector-manager'; export class GoFeatureFlagWebProvider implements Provider { metadata = { @@ -49,6 +52,8 @@ export class GoFeatureFlagWebProvider implements Provider { private _websocket?: WebSocket; // _flags is the in memory representation of all the flags. private _flags: { [key: string]: ResolutionDetails<FlagValue> } = {}; + + private readonly _collectorManager: CollectorManager; private readonly _dataCollectorHook: GoFeatureFlagDataCollectorHook; // disableDataCollection set to true if you don't want to collect the usage of flags retrieved in the cache. private readonly _disableDataCollection: boolean; @@ -62,13 +67,15 @@ export class GoFeatureFlagWebProvider implements Provider { this._maxRetries = options.maxRetries || 10; this._apiKey = options.apiKey; this._disableDataCollection = options.disableDataCollection || false; - this._dataCollectorHook = new GoFeatureFlagDataCollectorHook(options, logger); + + this._collectorManager = new CollectorManager(options, logger); + this._dataCollectorHook = new GoFeatureFlagDataCollectorHook(this._collectorManager); } async initialize(context: EvaluationContext): Promise<void> { if (!this._disableDataCollection && this._dataCollectorHook) { this.hooks = [this._dataCollectorHook]; - this._dataCollectorHook.init(); + this._collectorManager.init(); } return Promise.all([this.fetchAll(context), this.connectWebsocket()]) .then(() => { @@ -157,8 +164,8 @@ export class GoFeatureFlagWebProvider implements Provider { } async onClose(): Promise<void> { - if (!this._disableDataCollection && this._dataCollectorHook) { - await this._dataCollectorHook?.close(); + if (!this._disableDataCollection && this._collectorManager) { + await this._collectorManager?.close(); } this._websocket?.close(1000, 'Closing GO Feature Flag provider'); return Promise.resolve(); @@ -187,6 +194,29 @@ export class GoFeatureFlagWebProvider implements Provider { return this.evaluate(flagKey, 'boolean'); } + /** + * Track allows to send tracking events to a tracking exporter. + * + * Warning: Note that you need to have a relay proxy with version 1.45.0 or upper to use this feature. + * If you are using a version lower than 1.45.0, the events may look weird in your exporter. + * + * @param trackingEventName + * @param context + * @param trackingEventDetails + */ + track(trackingEventName: string, context: EvaluationContext, trackingEventDetails: TrackingEventDetails): void { + const trackingEvent: TrackingEvent = { + kind: 'tracking', + contextKind: context['anonymous'] ? 'anonymousUser' : 'user', + creationDate: Math.round(Date.now() / 1000), + key: trackingEventName, + evaluationContext: context, + trackingEventDetails: trackingEventDetails, + userKey: context.targetingKey || 'undefined-targetingKey', + }; + this._collectorManager?.add(trackingEvent); + } + /** * extract flag names from the websocket answer */ diff --git a/libs/providers/go-feature-flag-web/src/lib/model.ts b/libs/providers/go-feature-flag-web/src/lib/model.ts index 8982c2375..8fa9ba20d 100644 --- a/libs/providers/go-feature-flag-web/src/lib/model.ts +++ b/libs/providers/go-feature-flag-web/src/lib/model.ts @@ -1,5 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { ErrorCode, EvaluationContextValue, FlagValue } from '@openfeature/web-sdk'; +import { + ErrorCode, + EvaluationContext, + EvaluationContextValue, + FlagValue, + TrackingEventDetails, +} from '@openfeature/web-sdk'; /** * GoFeatureFlagEvaluationContext is the representation of a user for GO Feature Flag @@ -106,7 +112,7 @@ export interface GOFeatureFlagWebsocketResponse { } export interface DataCollectorRequest<T> { - events: FeatureEvent<T>[]; + events: Array<FeatureEvent<T> | TrackingEvent>; meta: Record<string, ExporterMetadataValue>; } @@ -122,3 +128,13 @@ export interface FeatureEvent<T> { version?: string; source?: string; } + +export interface TrackingEvent { + kind: string; + contextKind: string; + userKey: string; + creationDate: number; + key: string; + evaluationContext: EvaluationContext; + trackingEventDetails: TrackingEventDetails; +}