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:
Ahmad Abdolsaheb
2021-11-22 16:43:28 +03:00
committed by GitHub
parent 204114c00f
commit b4326f0ad6
11 changed files with 41693 additions and 46106 deletions

79607
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -119,6 +119,7 @@
"rxjs": "6.6.7",
"sanitize-html": "2.5.3",
"sass.js": "0.11.1",
"sha-1": "^1.0.0",
"store": "2.0.12",
"stream-browserify": "3.0.0",
"tone": "14.7.77",

View File

@ -15,6 +15,10 @@ declare module '*.png' {
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
// eslint-disable-next-line no-var
declare var MathJax: {

View File

@ -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 {
isSignedInSelector,
emailSelector,
completionCountSelector,
completedChallengesSelector
} from '../redux';
import { emailToABVariant } from '../utils/A-B-tester';
const GaTypes = { event: ga.event, page: ga.pageview, modal: ga.modalview };
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);
}

View File

@ -11,8 +11,8 @@ describe('ga-saga', () => {
const mockEventPayload = {
type: 'event',
data: {
category: 'Donation',
action: 'year end gift paypal button click'
category: 'Map Challenge Click',
action: '/learn'
}
};
return (

View File

@ -201,6 +201,7 @@ export const stepsToClaimSelector = state => {
isShowProfile: !user?.profileUI?.isLocked
};
};
export const emailSelector = state => userSelector(state).email;
export const isDonatingSelector = state => userSelector(state).isDonating;
export const isOnlineSelector = state => state[MainApp].isOnline;
export const isServerOnlineSelector = state => state[MainApp].isServerOnline;

View 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
);
});
});

View 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
};
}

View File

@ -92,6 +92,11 @@ const donationUrls = {
const patreonDefaultPledgeAmount = 500;
const aBTestConfig = {
isTesting: true,
type: 'DistributionTest'
};
module.exports = {
durationsConfig,
amountsConfig,
@ -105,5 +110,6 @@ module.exports = {
paypalConfigTypes,
paypalConfigurator,
donationUrls,
patreonDefaultPledgeAmount
patreonDefaultPledgeAmount,
aBTestConfig
};

8087
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -102,6 +102,7 @@
"@testing-library/jest-dom": "5.15.0",
"@testing-library/user-event": "13.5.0",
"@types/chai": "4.2.22",
"@types/faker": "^5.5.9",
"@types/inquirer": "8.1.3",
"@types/jest": "27.0.3",
"@types/loadable__component": "5.13.4",
@ -141,6 +142,7 @@
"eslint-plugin-react-hooks": "2.5.1",
"eslint-plugin-testing-library": "4.12.4",
"execa": "5.1.1",
"faker": "^5.5.3",
"husky": "7.0.4",
"jest": "27.3.1",
"js-yaml": "3.14.1",