diff --git a/flagsmith-core.ts b/flagsmith-core.ts index f1381c64..7ca72e09 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -56,12 +56,23 @@ const FLAGSMITH_CONFIG_ANALYTICS_KEY = "flagsmith_value_"; const FLAGSMITH_FLAG_ANALYTICS_KEY = "flagsmith_enabled_"; const FLAGSMITH_TRAIT_ANALYTICS_KEY = "flagsmith_trait_"; + +/*API configuration includes the /v1/ +This function replaces that version with another. +In future, we may exclude /v1/ from api configuration however this would be a breaking change*/ +function apiVersion(api:string, version:number) { + return api.replace("/v1/",`/v${version}/`) +} + const Flagsmith = class { _trigger?:(()=>void)|null= null _triggerLoadingState?:(()=>void)|null= null timestamp: number|null = null isLoading = false eventSource:EventSource|null = null + events: string[] = [] + splitTestingAnalytics=false; + constructor(props: Config) { if (props.fetch) { _fetch = props.fetch as LikeFetch; @@ -225,11 +236,11 @@ const Flagsmith = class { const { api } = this; if (!this.evaluationEvent || !this.evaluationContext.environment || !this.evaluationEvent[this.evaluationContext.environment.apiKey]) { - return + return Promise.resolve() } if (this.evaluationEvent && Object.getOwnPropertyNames(this.evaluationEvent).length !== 0 && Object.getOwnPropertyNames(this.evaluationEvent[this.evaluationContext.environment.apiKey]).length !== 0) { - return this.getJSON(api + 'analytics/flags/', 'POST', JSON.stringify(this.evaluationEvent[this.evaluationContext.environment.apiKey])) + return this.getJSON(apiVersion(`${api}`, this.splitTestingAnalytics?2:1) + 'analytics/flags/', 'POST', JSON.stringify(this.toAnalyticsPayload(this.evaluationEvent[this.evaluationContext.environment.apiKey]))) .then((res) => { if (!this.evaluationContext.environment) { return; @@ -248,6 +259,7 @@ const Flagsmith = class { this.log("Exception fetching evaluationEvent", err); }); } + return Promise.resolve() }; datadogRum: IDatadogRum | null = null; @@ -298,9 +310,11 @@ const Flagsmith = class { state, cacheOptions, angularHttpClient, + splitTestingAnalytics, _trigger, _triggerLoadingState, } = config; + this.splitTestingAnalytics = !!splitTestingAnalytics; evaluationContext.environment = environmentID ? {apiKey: environmentID} : evaluationContext.environment; if (!evaluationContext.environment || !evaluationContext.environment.apiKey) { throw new Error('Please provide `evaluationContext.environment` with non-empty `apiKey`'); @@ -553,6 +567,7 @@ const Flagsmith = class { // clear out old traits when switching identity traits: this.evaluationContext.identity && this.evaluationContext.identity.identifier == userId ? this.evaluationContext.identity.traits : {} } + this.events.map(this.trackEvent) this.evaluationContext.identity.identifier = userId; this.log("Identify: " + this.evaluationContext.identity.identifier) @@ -723,6 +738,25 @@ const Flagsmith = class { }); }; + trackEvent = (event: string) => { + if (!this.splitTestingAnalytics) { + const error = new Error("This feature is only enabled for self-hosted customers using split testing."); + console.error(error.message); + return Promise.reject(error); + } else if (!this.evaluationContext.identity?.identifier) { + this.events.push(event); + this.log("Waiting for user to be identified before tracking event", event); + return Promise.resolve(); + } else { + return this.analyticsFlags().then(() => { + return this.getJSON(this.api + 'split-testing/conversion-events/', "POST", JSON.stringify({ + 'identity_identifier': this.evaluationContext.identity?.identifier, + 'type': event + })); + }); + } + }; + hasFeature = (key: string, options?: HasFeatureOptions) => { // Support legacy skipAnalytics boolean parameter const usingNewOptions = typeof options === 'object' @@ -759,6 +793,26 @@ const Flagsmith = class { } } + + + private toAnalyticsPayload = (evaluations: Record|null)=> { + if(!this.splitTestingAnalytics) { + return evaluations || {} + } + if(!evaluations) return {evaluations: []} + return { + evaluations: Object.keys(evaluations).map((feature_name)=>( + { + feature_name, + "identity_identifier": this.evaluationContext?.identity?.identifier||null, + "count": evaluations[feature_name], + "enabled_when_evaluated": this.hasFeature(feature_name), + } + )) + } + }; + + private updateStorage() { if (this.cacheFlags) { this.ts = new Date().valueOf(); diff --git a/test/analytics.test.ts b/test/analytics.test.ts new file mode 100644 index 00000000..1ee38767 --- /dev/null +++ b/test/analytics.test.ts @@ -0,0 +1,116 @@ +// Sample test +import { getFlagsmith, getMockFetchWithValue, testIdentity } from './test-constants'; + +describe('Analytics', () => { + + beforeEach(() => { + jest.useFakeTimers(); // Mocked to allow time to pass for analytics flush + }); + afterEach(() => { + jest.useRealTimers(); + }); + test('should not attempt to track events when split testing is disabled', async () => { + const { flagsmith } = getFlagsmith({ + cacheFlags: true, + identity: testIdentity, + enableAnalytics: true, + splitTestingAnalytics: false, // Disable split testing + }); + + await expect(flagsmith.trackEvent("checkout")) + .rejects.toThrow('This feature is only enabled for self-hosted customers using split testing.'); + }); + test('should track v1 analytics', async () => { + const onChange = jest.fn(); + const fetchFn = getMockFetchWithValue({ + flags:[{feature:{name:"font_size"}, enabled: true}, {feature:{name:"off_value"}, enabled: false}], + }); + const { flagsmith, initConfig, mockFetch } = getFlagsmith({ + cacheFlags: true, + identity: testIdentity, + enableAnalytics: true, + onChange, + }, fetchFn); + await flagsmith.init(initConfig); + flagsmith.getValue("font_size") + flagsmith.hasFeature("off_value") + flagsmith.hasFeature("off_value") + jest.advanceTimersByTime(10000); + expect(mockFetch).toHaveBeenCalledWith( + `${flagsmith.api}analytics/flags/`, + { + method: 'POST', + body: JSON.stringify({ + font_size: 1, + off_value: 2, + }), + cache: 'no-cache', + headers: { + 'X-Environment-Key': flagsmith.getContext().environment?.apiKey, + 'Content-Type': 'application/json; charset=utf-8', + }, + }, + ); + }); + test('should track conversion events when trackEvent is called', async () => { + const onChange = jest.fn(); + const fetchFn = getMockFetchWithValue({ + flags:[{feature:{name:"font_size"}, enabled: true}, {feature:{name:"off_value"}, enabled: false}], + }); + const { flagsmith, initConfig, mockFetch } = getFlagsmith({ + cacheFlags: true, + identity: testIdentity, + enableAnalytics: true, + splitTestingAnalytics: true, + onChange, + }, fetchFn); + await flagsmith.init(initConfig); + flagsmith.getValue("font_size") + flagsmith.hasFeature("off_value") + flagsmith.hasFeature("off_value") + await flagsmith.trackEvent('checkout'); + + expect(mockFetch).toHaveBeenCalledWith( + `${flagsmith.api}split-testing/conversion-events/`, + { + method: 'POST', + body: JSON.stringify({ + identity_identifier: testIdentity, + type: 'checkout', + }), + cache: 'no-cache', + headers: { + 'X-Environment-Key': flagsmith.getContext().environment?.apiKey, + 'Content-Type': 'application/json; charset=utf-8', + }, + }, + ); + expect(mockFetch).toHaveBeenCalledWith( + `${flagsmith.api.replace('/v1/', '/v2/')}analytics/flags/`, + { + method: 'POST', + body: JSON.stringify({ + "evaluations": [ + { + "feature_name": "font_size", + "identity_identifier": "test_identity", + "count": 1, + "enabled_when_evaluated": true + }, + { + "feature_name": "off_value", + "identity_identifier": "test_identity", + "count": 2, + "enabled_when_evaluated": false + } + ] + }), + cache: 'no-cache', + headers: { + 'X-Environment-Key': flagsmith.getContext().environment?.apiKey, + 'Content-Type': 'application/json; charset=utf-8', + }, + }, + ); + }); +}); diff --git a/test/test-constants.ts b/test/test-constants.ts index 0da6b4cf..2134298f 100644 --- a/test/test-constants.ts +++ b/test/test-constants.ts @@ -1,9 +1,7 @@ import { IInitConfig, IState } from '../lib/flagsmith/types'; import MockAsyncStorage from './mocks/async-storage-mock'; import { createFlagsmithInstance } from '../lib/flagsmith'; -import Mock = jest.Mock; import { promises as fs } from 'fs'; - export const environmentID = 'QjgYur4LQTwe5HpvbvhpzK'; // Flagsmith Demo Projects export const FLAGSMITH_KEY = 'FLAGSMITH_DB' + "_" + environmentID; export const defaultState = { @@ -70,10 +68,10 @@ export function getStateToCheck(_state: IState) { return state; } -export function getFlagsmith(config: Partial = {}) { +export function getFlagsmith(config: Partial = {}, _mockFetch?:any) { const flagsmith = createFlagsmithInstance(); const AsyncStorage = new MockAsyncStorage(); - const mockFetch = jest.fn(async (url, options) => { + const mockFetch = _mockFetch||jest.fn(async (url, options) => { switch (url) { case 'https://edge.api.flagsmith.com/api/v1/flags/': return {status: 200, text: () => fs.readFile('./test/data/flags.json', 'utf8')} @@ -98,10 +96,12 @@ export function getFlagsmith(config: Partial = {}) { return { flagsmith, initConfig, mockFetch, AsyncStorage }; } export const delay = (ms:number) => new Promise((resolve) => setTimeout(resolve, ms)); -export function getMockFetchWithValue(mockFn:Mock, resolvedValue:object, ms=0) { - mockFn.mockReturnValueOnce(delay(ms).then(()=>Promise.resolve({ - status:200, - text: () => Promise.resolve(JSON.stringify(resolvedValue)), // Mock json() to return the mock response - json: () => Promise.resolve(resolvedValue), // Mock json() to return the mock response - }))) +export function getMockFetchWithValue(resolvedValue:object, status=200) { + return jest.fn(() => + Promise.resolve({ + status, + text: () => Promise.resolve(JSON.stringify(resolvedValue)), // Mock json() to return the mock response + json: () => Promise.resolve(resolvedValue), // Mock json() to return the mock response + }) + ); } diff --git a/types.d.ts b/types.d.ts index 1dae9964..78b358b8 100644 --- a/types.d.ts +++ b/types.d.ts @@ -90,16 +90,15 @@ export declare type LoadingState = { export type OnChange = (previousFlags: IFlags | null, params: IRetrieveInfo, loadingState:LoadingState) => void export interface IInitConfig { + environmentID: string; AsyncStorage?: any; + angularHttpClient?: any; api?: string; evaluationContext?: ClientEvaluationContext; cacheFlags?: boolean; cacheOptions?: ICacheOptions; datadogRum?: IDatadogRum; defaultFlags?: IFlags; - fetch?: any; - realtime?: boolean; - eventSourceUrl?: string; enableAnalytics?: boolean; enableDynatrace?: boolean; enableLogs?: boolean; @@ -108,6 +107,8 @@ export interface IInitConfig; onError?: (err: Error) => void; preventFetch?: boolean; + realtime?: boolean; + splitTestingAnalytics?: boolean; state?: IState; + traits?: ITraits; _trigger?: () => void; _triggerLoadingState?: () => void; } @@ -240,6 +244,11 @@ export interface IFlagsmith Promise; + /** + * Only available for self hosted split testing analytics. + * Track a conversion event within your application, used for split testing analytics. + */ + trackEvent: (event: string) => Promise; /** * * @deprecated Please consider using evaluationContext.identity: {@link IFlagsmith.getContext}. * */