Skip to content

Commit aa37e09

Browse files
Jakeiii-hardy
andcommitted
Add client for reading new ab test framework state
Co-authored-by: Imogen Hardy <[email protected]>
1 parent c49d7c2 commit aa37e09

File tree

12 files changed

+213
-7
lines changed

12 files changed

+213
-7
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { getCookie, isUndefined } from '@guardian/libs';
2+
3+
const AB_COOKIE_NAME = 'gu_client_ab_tests';
4+
5+
type ABParticipations = {
6+
[testId: string]: string;
7+
};
8+
9+
/**
10+
* get client-side AB test state from the cookie
11+
*/
12+
const getClientParticipations = (): ABParticipations => {
13+
const userTestBuckets = getCookie({
14+
name: AB_COOKIE_NAME,
15+
shouldMemoize: true,
16+
});
17+
18+
if (userTestBuckets) {
19+
return userTestBuckets
20+
.split(',')
21+
.reduce<ABParticipations>((participations, abTestStatus) => {
22+
const [testId, groupId] = abTestStatus.split(':');
23+
if (testId && groupId) {
24+
participations[testId] = groupId;
25+
}
26+
return participations;
27+
}, {});
28+
}
29+
30+
return {};
31+
};
32+
const initABTesting = (): void => {
33+
const { serverSideABTests } = window.guardian.config;
34+
35+
const clientSideABTests = getClientParticipations();
36+
37+
const participations = {
38+
...clientSideABTests,
39+
...serverSideABTests,
40+
};
41+
42+
window.guardian.modules.abTests = {
43+
getParticipations: () => participations,
44+
isUserInTest: (testId: string) => {
45+
return !isUndefined(participations[testId]);
46+
},
47+
isUserInTestGroup: (testId: string, groupId: string) => {
48+
return participations[testId] === groupId;
49+
},
50+
};
51+
};
52+
53+
export { initABTesting };

dotcom-rendering/src/client/main.web.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@ void (async () => {
5151
},
5252
);
5353

54+
void startup(
55+
'abTesting',
56+
() =>
57+
import(/* webpackMode: 'eager' */ './abTesting').then(
58+
({ initABTesting }) => initABTesting(),
59+
),
60+
{ priority: 'critical' },
61+
);
62+
5463
void startup(
5564
'dynamicImport',
5665
() =>

dotcom-rendering/src/components/ArticlePage.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,9 @@ export const ArticlePage = (props: WebProps | AppProps) => {
134134
pageIsSensitive={frontendData.config.isSensitive}
135135
isDev={!!frontendData.config.isDev}
136136
serverSideTests={frontendData.config.abTests}
137+
serverSideABTests={
138+
frontendData.config.serverSideABTests
139+
}
137140
/>
138141
</Island>
139142
</>

dotcom-rendering/src/components/FrontPage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export const FrontPage = ({ front, NAV }: Props) => {
8080
pageIsSensitive={front.config.isSensitive}
8181
isDev={!!front.config.isDev}
8282
serverSideTests={front.config.abTests}
83+
serverSideABTests={front.config.serverSideABTests}
8384
/>
8485
</Island>
8586

dotcom-rendering/src/components/SetABTests.importable.tsx

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import type { CoreAPIConfig } from '@guardian/ab-core';
22
import { AB } from '@guardian/ab-core';
33
import { getCookie, isUndefined, log } from '@guardian/libs';
4-
import { useEffect, useState } from 'react';
4+
import { useEffect, useMemo, useState } from 'react';
55
import { getOphan } from '../client/ophan/ophan';
66
import { tests } from '../experiments/ab-tests';
77
import { runnableTestsToParticipations } from '../experiments/lib/ab-participations';
8+
import { BetaABTests } from '../experiments/lib/beta-ab-tests';
89
import { getForcedParticipationsFromUrl } from '../lib/getAbUrlHash';
9-
import { setABTests } from '../lib/useAB';
10+
import { setABTests, setBetaABTests } from '../lib/useAB';
1011
import type { ABTestSwitches } from '../model/enhance-switches';
1112
import type { ServerSideTests } from '../types/config';
1213
import { useConfig } from './ConfigContext';
@@ -17,6 +18,7 @@ type Props = {
1718
isDev: boolean;
1819
pageIsSensitive: CoreAPIConfig['pageIsSensitive'];
1920
serverSideTests: ServerSideTests;
21+
serverSideABTests: Record<string, string>;
2022
};
2123

2224
const mvtMinValue = 1;
@@ -49,6 +51,12 @@ const getLocalMvtId = () =>
4951
}),
5052
);
5153

54+
const errorReporter = (e: unknown) =>
55+
window.guardian.modules.sentry.reportError(
56+
e instanceof Error ? e : Error(String(e)),
57+
'ab-tests',
58+
);
59+
5260
/**
5361
* Initialises the values of `useAB` and sends relevant Ophan events.
5462
*
@@ -66,6 +74,7 @@ export const SetABTests = ({
6674
abTestSwitches,
6775
forcedTestVariants,
6876
serverSideTests,
77+
serverSideABTests,
6978
}: Props) => {
7079
const { renderingTarget } = useConfig();
7180
const [ophan, setOphan] = useState<Awaited<ReturnType<typeof getOphan>>>();
@@ -81,6 +90,16 @@ export const SetABTests = ({
8190
});
8291
}, [renderingTarget]);
8392

93+
const betaAb = useMemo(
94+
() =>
95+
new BetaABTests({
96+
serverSideABTests,
97+
}),
98+
[serverSideABTests],
99+
);
100+
101+
setBetaABTests(betaAb);
102+
84103
useEffect(() => {
85104
if (!ophan) return;
86105

@@ -106,11 +125,7 @@ export const SetABTests = ({
106125
forcedTestVariants: allForcedTestVariants,
107126
ophanRecord: ophan.record,
108127
serverSideTests,
109-
errorReporter: (e) =>
110-
window.guardian.modules.sentry.reportError(
111-
e instanceof Error ? e : Error(String(e)),
112-
'ab-tests',
113-
),
128+
errorReporter,
114129
});
115130
const allRunnableTests = ab.allRunnableTests(tests);
116131
const participations = runnableTestsToParticipations(allRunnableTests);
@@ -123,6 +138,9 @@ export const SetABTests = ({
123138
ab.trackABTests(allRunnableTests);
124139
ab.registerImpressionEvents(allRunnableTests);
125140
ab.registerCompleteEvents(allRunnableTests);
141+
142+
betaAb.trackABTests(ophan.record, errorReporter);
143+
126144
log('dotcom', 'AB tests initialised');
127145
}, [
128146
abTestSwitches,
@@ -131,6 +149,7 @@ export const SetABTests = ({
131149
pageIsSensitive,
132150
ophan,
133151
serverSideTests,
152+
betaAb,
134153
]);
135154

136155
// we don’t render anything

dotcom-rendering/src/components/SportDataPageComponent.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export const SportDataPageComponent = ({ sportData }: Props) => {
7272
pageIsSensitive={sportData.config.isSensitive}
7373
isDev={!!sportData.config.isDev}
7474
serverSideTests={sportData.config.abTests}
75+
serverSideABTests={sportData.config.serverSideABTests}
7576
/>
7677
</Island>
7778
<Island priority="critical">

dotcom-rendering/src/components/TagPage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export const TagPage = ({ tagPage, NAV }: Props) => {
7676
pageIsSensitive={tagPage.config.isSensitive}
7777
isDev={!!tagPage.config.isDev}
7878
serverSideTests={tagPage.config.abTests}
79+
serverSideABTests={tagPage.config.serverSideABTests}
7980
/>
8081
</Island>
8182
<Island priority="critical">
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { isServer } from '../../lib/isServer';
2+
3+
export interface BetaABTestAPI {
4+
isUserInTest: (testId: string, variantId: string) => boolean;
5+
trackABTests: (
6+
ophanRecord: OphanRecordFunction,
7+
errorReporter: ErrorReporter,
8+
) => void;
9+
}
10+
11+
type ABParticipations = {
12+
[testId: string]: string;
13+
};
14+
15+
interface OphanABEvent {
16+
variantName: string;
17+
complete: string | boolean;
18+
campaignCodes?: readonly string[];
19+
}
20+
21+
type OphanABPayload = Record<string, OphanABEvent>;
22+
23+
type OphanRecordFunction = (send: Record<string, OphanABPayload>) => void;
24+
25+
type ErrorReporter = (e: unknown) => void;
26+
27+
type BetaABTestsConfig = {
28+
serverSideABTests: Record<string, string>;
29+
};
30+
31+
/**
32+
* generate an A/B event for Ophan
33+
*/
34+
const makeABEvent = (variantName: string, complete: boolean): OphanABEvent => {
35+
const event: OphanABEvent = {
36+
variantName,
37+
complete,
38+
};
39+
40+
return event;
41+
};
42+
43+
export class BetaABTests implements BetaABTestAPI {
44+
private participations: ABParticipations;
45+
46+
constructor({ serverSideABTests }: BetaABTestsConfig) {
47+
if (isServer) {
48+
this.participations = serverSideABTests;
49+
} else {
50+
this.participations =
51+
window.guardian.modules.abTests?.getParticipations() ?? {};
52+
}
53+
}
54+
55+
isUserInTest(testId: string, variantId: string): boolean {
56+
return this.participations[testId] === variantId;
57+
}
58+
59+
trackABTests(
60+
ophanRecord: OphanRecordFunction,
61+
errorReporter: ErrorReporter,
62+
): void {
63+
ophanRecord({
64+
abTestRegister: this.buildOphanPayload(errorReporter),
65+
});
66+
}
67+
68+
private buildOphanPayload(errorReporter: ErrorReporter) {
69+
try {
70+
const testAndVariantIds = Object.entries(this.participations);
71+
72+
return testAndVariantIds.reduce<OphanABPayload>(
73+
(eventLog, [testId, variantId]) => {
74+
eventLog[testId] = makeABEvent(variantId, false);
75+
return eventLog;
76+
},
77+
{},
78+
);
79+
} catch (error: unknown) {
80+
// Encountering an error should invalidate the logging process.
81+
errorReporter(error);
82+
return {};
83+
}
84+
}
85+
}

dotcom-rendering/src/frontend/feFront.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ export type FEFrontConfig = {
340340
sharedAdTargeting: SharedAdTargeting;
341341
buildNumber: string;
342342
abTests: ServerSideTests;
343+
serverSideABTests: Record<string, string>;
343344
pbIndexSites: { [key: string]: unknown }[];
344345
ampIframeUrl: string;
345346
beaconUrl: string;

dotcom-rendering/src/lib/useAB.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { ABTestAPI, Participations } from '@guardian/ab-core';
22
import { mutate } from 'swr';
33
import useSWRImmutable from 'swr/immutable';
4+
import type { BetaABTestAPI } from '../experiments/lib/beta-ab-tests';
45

56
type ABTests = {
67
api: ABTestAPI;
@@ -26,3 +27,15 @@ export const useAB = (): ABTests | undefined => {
2627
export const setABTests = ({ api, participations }: ABTests): void => {
2728
void mutate(key, { api, participations }, false);
2829
};
30+
31+
export const useBetaAB = (): BetaABTestAPI | undefined => {
32+
const { data } = useSWRImmutable(
33+
'beta-ab-tests',
34+
() => new Promise<BetaABTestAPI>(() => {}),
35+
);
36+
return data;
37+
};
38+
39+
export const setBetaABTests = (api: BetaABTestAPI): void => {
40+
void mutate('beta-ab-tests', api, false);
41+
};

0 commit comments

Comments
 (0)