feat: add email to A/B function (#44187)
* feat: add email to A/B function * fix: declare types for sha-1 * Update client/src/utils/A-B-tester.ts Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com> * feat: add custom dimesions for donation events * feat: re-order if statemetns * Apply suggestions from code review Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com> * fix: assuage TypeScript * update rename * rename vars * update naming * re add types * Update client/src/redux/ga-saga.js Co-authored-by: Shaun Hamilton <shauhami020@gmail.com> Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com> Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
This commit is contained in:
11
client/package-lock.json
generated
11
client/package-lock.json
generated
@ -95,6 +95,7 @@
|
|||||||
"rxjs": "6.6.7",
|
"rxjs": "6.6.7",
|
||||||
"sanitize-html": "2.5.3",
|
"sanitize-html": "2.5.3",
|
||||||
"sass.js": "0.11.1",
|
"sass.js": "0.11.1",
|
||||||
|
"sha-1": "^1.0.0",
|
||||||
"store": "2.0.12",
|
"store": "2.0.12",
|
||||||
"stream-browserify": "3.0.0",
|
"stream-browserify": "3.0.0",
|
||||||
"tone": "14.7.77",
|
"tone": "14.7.77",
|
||||||
@ -19511,6 +19512,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz",
|
||||||
"integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw=="
|
"integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/sha-1": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sha-1/-/sha-1-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-qjFA/+LdT0Gvu/JcmYTGZMvVy6WXJOWv1KQuY7HvSr2oBrMxA8PnZu2mc1/ZS2EvLMokj7lIeQsNPjkRzXrImw=="
|
||||||
|
},
|
||||||
"node_modules/sha.js": {
|
"node_modules/sha.js": {
|
||||||
"version": "2.4.11",
|
"version": "2.4.11",
|
||||||
"resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
|
"resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
|
||||||
@ -37415,6 +37421,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz",
|
||||||
"integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw=="
|
"integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw=="
|
||||||
},
|
},
|
||||||
|
"sha-1": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sha-1/-/sha-1-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-qjFA/+LdT0Gvu/JcmYTGZMvVy6WXJOWv1KQuY7HvSr2oBrMxA8PnZu2mc1/ZS2EvLMokj7lIeQsNPjkRzXrImw=="
|
||||||
|
},
|
||||||
"sha.js": {
|
"sha.js": {
|
||||||
"version": "2.4.11",
|
"version": "2.4.11",
|
||||||
"resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
|
"resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
|
||||||
|
@ -119,6 +119,7 @@
|
|||||||
"rxjs": "6.6.7",
|
"rxjs": "6.6.7",
|
||||||
"sanitize-html": "2.5.3",
|
"sanitize-html": "2.5.3",
|
||||||
"sass.js": "0.11.1",
|
"sass.js": "0.11.1",
|
||||||
|
"sha-1": "^1.0.0",
|
||||||
"store": "2.0.12",
|
"store": "2.0.12",
|
||||||
"stream-browserify": "3.0.0",
|
"stream-browserify": "3.0.0",
|
||||||
"tone": "14.7.77",
|
"tone": "14.7.77",
|
||||||
|
4
client/src/declarations.d.ts
vendored
4
client/src/declarations.d.ts
vendored
@ -15,6 +15,10 @@ declare module '*.png' {
|
|||||||
export default content;
|
export default content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module 'sha-1' {
|
||||||
|
export default function sha1(str: string): string;
|
||||||
|
}
|
||||||
|
|
||||||
// This has to be declared as var, not let or const, to be added to globalThis
|
// This has to be declared as var, not let or const, to be added to globalThis
|
||||||
// eslint-disable-next-line no-var
|
// eslint-disable-next-line no-var
|
||||||
declare var MathJax: {
|
declare var MathJax: {
|
||||||
|
@ -1,9 +1,39 @@
|
|||||||
import { takeEvery, call, all } from 'redux-saga/effects';
|
/* eslint-disable camelcase */
|
||||||
|
import { takeEvery, call, all, select } from 'redux-saga/effects';
|
||||||
|
import { aBTestConfig } from '../../../config/donation-settings';
|
||||||
import ga from '../analytics';
|
import ga from '../analytics';
|
||||||
|
import {
|
||||||
|
isSignedInSelector,
|
||||||
|
emailSelector,
|
||||||
|
completionCountSelector,
|
||||||
|
completedChallengesSelector
|
||||||
|
} from '../redux';
|
||||||
|
import { emailToABVariant } from '../utils/A-B-tester';
|
||||||
|
|
||||||
const GaTypes = { event: ga.event, page: ga.pageview, modal: ga.modalview };
|
const GaTypes = { event: ga.event, page: ga.pageview, modal: ga.modalview };
|
||||||
|
|
||||||
function* callGaType({ payload: { type, data } }) {
|
function* callGaType({ payload: { type, data } }) {
|
||||||
|
if (
|
||||||
|
type === 'event' &&
|
||||||
|
data.category.includes('Donation') &&
|
||||||
|
aBTestConfig.isTesting
|
||||||
|
) {
|
||||||
|
const isSignedIn = yield select(isSignedInSelector);
|
||||||
|
if (isSignedIn) {
|
||||||
|
const email = yield select(emailSelector);
|
||||||
|
const completedChallengeTotal = yield select(completedChallengesSelector);
|
||||||
|
const completedChallengeSession = yield select(completionCountSelector);
|
||||||
|
const customDimensions = {
|
||||||
|
Test_Variation: emailToABVariant(email).isAVariant ? 'A' : 'B',
|
||||||
|
Test_Type: aBTestConfig.type,
|
||||||
|
Challenges_Completed_Session: completedChallengeSession,
|
||||||
|
Challenges_Completed_Total: completedChallengeTotal.length,
|
||||||
|
URL: window.location.href
|
||||||
|
};
|
||||||
|
data = { ...data, ...customDimensions };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
yield call(GaTypes[type], data);
|
yield call(GaTypes[type], data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,8 +11,8 @@ describe('ga-saga', () => {
|
|||||||
const mockEventPayload = {
|
const mockEventPayload = {
|
||||||
type: 'event',
|
type: 'event',
|
||||||
data: {
|
data: {
|
||||||
category: 'Donation',
|
category: 'Map Challenge Click',
|
||||||
action: 'year end gift paypal button click'
|
action: '/learn'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
|
@ -201,6 +201,7 @@ export const stepsToClaimSelector = state => {
|
|||||||
isShowProfile: !user?.profileUI?.isLocked
|
isShowProfile: !user?.profileUI?.isLocked
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
export const emailSelector = state => userSelector(state).email;
|
||||||
export const isDonatingSelector = state => userSelector(state).isDonating;
|
export const isDonatingSelector = state => userSelector(state).isDonating;
|
||||||
export const isOnlineSelector = state => state[MainApp].isOnline;
|
export const isOnlineSelector = state => state[MainApp].isOnline;
|
||||||
export const isServerOnlineSelector = state => state[MainApp].isServerOnline;
|
export const isServerOnlineSelector = state => state[MainApp].isServerOnline;
|
||||||
|
32
client/src/utils/A-B-tester.test.ts
Normal file
32
client/src/utils/A-B-tester.test.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import faker from 'faker';
|
||||||
|
import { emailToABVariant } from './A-B-tester';
|
||||||
|
|
||||||
|
describe('client/src is-email-variation-a', () => {
|
||||||
|
it('Consistently returns the same result for the same input', () => {
|
||||||
|
const preSavedResult = {
|
||||||
|
hash: '23e3cacb302b0c759531faa8b414b23709c26029',
|
||||||
|
isAVariant: true,
|
||||||
|
hashInt: 2
|
||||||
|
};
|
||||||
|
const result = emailToABVariant('foo@freecodecamp.org');
|
||||||
|
expect(result).toEqual(preSavedResult);
|
||||||
|
});
|
||||||
|
it('Distributes A and B variations equaly for 100K random emails', () => {
|
||||||
|
let A = 0;
|
||||||
|
let B = 0;
|
||||||
|
const sampleSize = 100000;
|
||||||
|
faker.seed(123);
|
||||||
|
|
||||||
|
for (let i = 0; i < sampleSize; i++) {
|
||||||
|
if (emailToABVariant(faker.internet.email()).isAVariant) A++;
|
||||||
|
else B++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBucketWellDistributed = (variant: number): boolean =>
|
||||||
|
variant > 0.99 * (sampleSize / 2);
|
||||||
|
|
||||||
|
expect(isBucketWellDistributed(A) && isBucketWellDistributed(B)).toEqual(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
21
client/src/utils/A-B-tester.ts
Normal file
21
client/src/utils/A-B-tester.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import sha1 from 'sha-1';
|
||||||
|
|
||||||
|
// This function turns an email to a hash and decides if it should be
|
||||||
|
// an A or B variant for A/B testing
|
||||||
|
|
||||||
|
export function emailToABVariant(email: string): {
|
||||||
|
hash: string;
|
||||||
|
isAVariant: boolean;
|
||||||
|
hashInt: number;
|
||||||
|
} {
|
||||||
|
// turn the email into a number
|
||||||
|
const hash: string = sha1(email);
|
||||||
|
const hashInt = parseInt(hash.slice(0, 1), 16);
|
||||||
|
// turn the number to A or B variant
|
||||||
|
const isAVariant = hashInt % 2 === 0;
|
||||||
|
return {
|
||||||
|
hash,
|
||||||
|
isAVariant,
|
||||||
|
hashInt
|
||||||
|
};
|
||||||
|
}
|
@ -92,6 +92,11 @@ const donationUrls = {
|
|||||||
|
|
||||||
const patreonDefaultPledgeAmount = 500;
|
const patreonDefaultPledgeAmount = 500;
|
||||||
|
|
||||||
|
const aBTestConfig = {
|
||||||
|
isTesting: true,
|
||||||
|
type: 'DistributionTest'
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
durationsConfig,
|
durationsConfig,
|
||||||
amountsConfig,
|
amountsConfig,
|
||||||
@ -105,5 +110,6 @@ module.exports = {
|
|||||||
paypalConfigTypes,
|
paypalConfigTypes,
|
||||||
paypalConfigurator,
|
paypalConfigurator,
|
||||||
donationUrls,
|
donationUrls,
|
||||||
patreonDefaultPledgeAmount
|
patreonDefaultPledgeAmount,
|
||||||
|
aBTestConfig
|
||||||
};
|
};
|
||||||
|
8087
package-lock.json
generated
8087
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -102,6 +102,7 @@
|
|||||||
"@testing-library/jest-dom": "5.15.0",
|
"@testing-library/jest-dom": "5.15.0",
|
||||||
"@testing-library/user-event": "13.5.0",
|
"@testing-library/user-event": "13.5.0",
|
||||||
"@types/chai": "4.2.22",
|
"@types/chai": "4.2.22",
|
||||||
|
"@types/faker": "^5.5.9",
|
||||||
"@types/inquirer": "8.1.3",
|
"@types/inquirer": "8.1.3",
|
||||||
"@types/jest": "27.0.3",
|
"@types/jest": "27.0.3",
|
||||||
"@types/loadable__component": "5.13.4",
|
"@types/loadable__component": "5.13.4",
|
||||||
@ -141,6 +142,7 @@
|
|||||||
"eslint-plugin-react-hooks": "2.5.1",
|
"eslint-plugin-react-hooks": "2.5.1",
|
||||||
"eslint-plugin-testing-library": "4.12.4",
|
"eslint-plugin-testing-library": "4.12.4",
|
||||||
"execa": "5.1.1",
|
"execa": "5.1.1",
|
||||||
|
"faker": "^5.5.3",
|
||||||
"husky": "7.0.4",
|
"husky": "7.0.4",
|
||||||
"jest": "27.3.1",
|
"jest": "27.3.1",
|
||||||
"js-yaml": "3.14.1",
|
"js-yaml": "3.14.1",
|
||||||
|
Reference in New Issue
Block a user