Skip to content

feat: v2 analytics and split testing #247

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

Closed
wants to merge 21 commits into from
Closed
Show file tree
Hide file tree
Changes from all 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
58 changes: 56 additions & 2 deletions flagsmith-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -248,6 +259,7 @@ const Flagsmith = class {
this.log("Exception fetching evaluationEvent", err);
});
}
return Promise.resolve()
};

datadogRum: IDatadogRum | null = null;
Expand Down Expand Up @@ -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`');
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -759,6 +793,26 @@ const Flagsmith = class {
}
}



private toAnalyticsPayload = (evaluations: Record<string, number>|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();
Expand Down
116 changes: 116 additions & 0 deletions test/analytics.test.ts
Original file line number Diff line number Diff line change
@@ -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',
},
},
);
});
});
20 changes: 10 additions & 10 deletions test/test-constants.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -70,10 +68,10 @@ export function getStateToCheck(_state: IState) {
return state;
}

export function getFlagsmith(config: Partial<IInitConfig> = {}) {
export function getFlagsmith(config: Partial<IInitConfig> = {}, _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')}
Expand All @@ -98,10 +96,12 @@ export function getFlagsmith(config: Partial<IInitConfig> = {}) {
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
})
);
}
15 changes: 12 additions & 3 deletions types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,16 +90,15 @@ export declare type LoadingState = {

export type OnChange<F extends string = string> = (previousFlags: IFlags<F> | null, params: IRetrieveInfo, loadingState:LoadingState) => void
export interface IInitConfig<F extends string = string, T extends string = string> {
environmentID: string;
AsyncStorage?: any;
angularHttpClient?: any;
api?: string;
evaluationContext?: ClientEvaluationContext;
cacheFlags?: boolean;
cacheOptions?: ICacheOptions;
datadogRum?: IDatadogRum;
defaultFlags?: IFlags<F>;
fetch?: any;
realtime?: boolean;
eventSourceUrl?: string;
enableAnalytics?: boolean;
enableDynatrace?: boolean;
enableLogs?: boolean;
Expand All @@ -108,6 +107,8 @@ export interface IInitConfig<F extends string = string, T extends string = strin
* * @deprecated Please consider using evaluationContext.identity: {@link IInitConfig.evaluationContext}.
* */
environmentID?: string;
eventSourceUrl?: string;
fetch?: any;
headers?: object;
/**
* * @deprecated Please consider using evaluationContext.identity: {@link IInitConfig.evaluationContext}.
Expand All @@ -120,7 +121,10 @@ export interface IInitConfig<F extends string = string, T extends string = strin
onChange?: OnChange<F>;
onError?: (err: Error) => void;
preventFetch?: boolean;
realtime?: boolean;
splitTestingAnalytics?: boolean;
state?: IState;
traits?: ITraits<T>;
_trigger?: () => void;
_triggerLoadingState?: () => void;
}
Expand Down Expand Up @@ -240,6 +244,11 @@ export interface IFlagsmith<F extends string = string, T extends string = string
* @deprecated in favour of {@link IFlagsmith.setContext}.
*/
setTraits: (traits: ITraits) => Promise<void>;
/**
* Only available for self hosted split testing analytics.
* Track a conversion event within your application, used for split testing analytics.
*/
trackEvent: (event: string) => Promise<void>;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's safe to assume that events could be recorded at a much higher rate than flags are evaluated, so they should definitely be batched and flushed in the background. The current implementation is probably good enough to validate the idea in a PoC but I would exclude this method from this SDK's public/versioned API until we can implement event batching.

This method being async is also not ergonomic to use on event handlers for DOM events like clicking, hovering or navigating.

Copy link
Member Author

@kyle-ssg kyle-ssg Sep 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't think of a case where we'd track events often, and even if they were it'd happen way less than flag evaluations since they'd occur potentially every render. The only usecase I can imagine is conversions e.g. checkout.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another use case for batching (or decoupling tracking from pushing) is for mobile/low-powered devices, where you might want to either not send events or reduce the rate at which they're sent when running on battery or on metered connections. This is a tradeoff between metrics accuracy and device impact that customers need to make, so I'd like to have some API that lets them make this decision.

I suppose nothing prevents customers from implementing the batching themselves if they really want, so maybe we could rename this to pushEvent and add trackEvent/flushEvents/whatever later. At least that way it's clearer what this method does, and leaves the door open for a batched API in the future.

/**
* * @deprecated Please consider using evaluationContext.identity: {@link IFlagsmith.getContext}.
* */
Expand Down