Skip to content
1 change: 1 addition & 0 deletions dotcom-rendering/src/components/FrontPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export const FrontPage = ({ front, NAV }: Props) => {
</Island>
<Island priority="critical">
<SetABTests
editorialAbTests={front.editorialAbTests}
serverSideABTests={front.config.serverSideABTests}
/>
</Island>
Expand Down
9 changes: 6 additions & 3 deletions dotcom-rendering/src/components/SetABTests.island.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import { getOphan } from '../client/ophan/ophan';
import { ABTests } from '../experiments/lib/ab-tests';
import { isServer } from '../lib/isServer';
import { setABTests } from '../lib/useAB';
import type { EditorialAbTest } from '../types/front';
import { useConfig } from './ConfigContext';

type Props = {
serverSideABTests: Record<string, string>;
editorialAbTests?: EditorialAbTest[];
};

const errorReporter = (e: unknown) =>
Expand All @@ -27,7 +29,7 @@ const errorReporter = (e: unknown) =>
*
* Does not render **anything**.
*/
export const SetABTests = ({ serverSideABTests }: Props) => {
export const SetABTests = ({ serverSideABTests, editorialAbTests }: Props) => {
const { renderingTarget } = useConfig();
const [ophan, setOphan] = useState<Awaited<ReturnType<typeof getOphan>>>();

Expand All @@ -46,14 +48,15 @@ export const SetABTests = ({ serverSideABTests }: Props) => {
const abTests = new ABTests(
isServer
? {
editorialAbTests,
serverSideABTests,
isServer: true,
}
: { isServer: false },
: { editorialAbTests, isServer: false },
);
setABTests(abTests);
return abTests;
}, [serverSideABTests]);
}, [serverSideABTests, editorialAbTests]);

useEffect(() => {
if (!ophan) {
Expand Down
33 changes: 30 additions & 3 deletions dotcom-rendering/src/experiments/lib/ab-tests.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { activeABtests } from '@guardian/ab-testing-config';
import { isUndefined } from '@guardian/libs';
import { getABTestParticipations } from '../../client/abTesting';
import { EditorialAbTest } from '../../types/front';

Check failure on line 4 in dotcom-rendering/src/experiments/lib/ab-tests.ts

View workflow job for this annotation

GitHub Actions / lint / check

All imports in the declaration are only used as types. Use `import type`

export interface ABTestAPI {
getParticipations: () => ABParticipations;
Expand Down Expand Up @@ -28,15 +29,18 @@

type ErrorReporter = (e: unknown) => void;

type ABTestsConfig =
type ABTestsConfig = {
editorialAbTests?: EditorialAbTest[];
} & (
| {
isServer: true;
serverSideABTests: Record<string, string>;
}
| {
isServer: false;
serverSideABTests?: never;
};
}
);

/**
* generate an A/B event for Ophan
Expand All @@ -52,8 +56,15 @@

export class ABTests implements ABTestAPI {
private participations: ABParticipations;
private editorialParticipations: EditorialAbTest[] | undefined;

constructor({
isServer,
serverSideABTests,
editorialAbTests,
}: ABTestsConfig) {
this.editorialParticipations = editorialAbTests;

constructor({ isServer, serverSideABTests }: ABTestsConfig) {
if (isServer) {
this.participations = serverSideABTests;
} else {
Expand All @@ -79,9 +90,25 @@
): void {
ophanRecord({
abTestRegister: this.buildOphanPayload(errorReporter),
editorialAbTestRegister: this.buildOphanEditorialPayload(),
});
}

private buildOphanEditorialPayload(): OphanABPayload {
return (
this.editorialParticipations?.reduce<OphanABPayload>(
(eventLog, test) => {
const variantId = test.variantMeta[0]?.id;

if (!variantId) return eventLog;
eventLog[test.testUuid] = makeABEvent(variantId, false);
return eventLog;
},
{},
) ?? {}
);
}

private shouldReportToOphan(testId: string): boolean {
const activeTest = activeABtests.find(({ name }) => name === testId);
return activeTest?.shouldReportToOphan
Expand Down
2 changes: 1 addition & 1 deletion dotcom-rendering/src/frontend/schemas/feFront.json
Original file line number Diff line number Diff line change
Expand Up @@ -4459,4 +4459,4 @@
}
},
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
2 changes: 1 addition & 1 deletion dotcom-rendering/src/frontend/schemas/feTagPage.json
Original file line number Diff line number Diff line change
Expand Up @@ -2149,4 +2149,4 @@
}
},
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
91 changes: 90 additions & 1 deletion dotcom-rendering/src/server/handler.front.web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
import { groupTrailsByDates } from '../model/groupTrailsByDates';
import { getSpeedFromTrails } from '../model/slowOrFastByTrails';
import { validateAsFEFront, validateAsFETagPage } from '../model/validate';
import type { Front } from '../types/front';
import type { EditorialAbTest, Front } from '../types/front';
import type { FETagType } from '../types/tag';
import type { TagPage } from '../types/tagPage';
import { makePrefetchHeader } from './lib/header';
Expand All @@ -23,6 +23,94 @@ const enhanceFront = (body: unknown): Front => {
const data: FEFront = validateAsFEFront(body);

const serverTime = Date.now();
/**
* mocked for spike purposes. In reality, we'd need to extract all tests available across all trails and then flatten into an array with the correct shape.
*/
const mockedEditorialAbTests: EditorialAbTest[] = [
{
testUuid: '123',
expiryDate: Date.now() - 10 * 60 * 1000, // ten mins ago,
frontsThisTestCanRunOn: ['US'],
hasManuallyEndedOnThisTrail: false,
variantMeta: [
{
id: 'A',
meta: {
headline: 'headline A',
},
},
{
id: 'B',
meta: {
headline: 'headline B',
},
},
],
},
{
testUuid: '456',
expiryDate: Date.now() + 10 * 60 * 1000, // ten mins in the future,
frontsThisTestCanRunOn: ['UK'],
hasManuallyEndedOnThisTrail: false,
variantMeta: [
{
id: 'A',
meta: {
headline: 'An A headline',
},
},
{
id: 'B',
meta: {
headline: 'A B headline',
},
},
],
},
{
testUuid: '789',
expiryDate: Date.now() + 60 * 60 * 1000, // an hour in the future,
frontsThisTestCanRunOn: ['US'],
hasManuallyEndedOnThisTrail: true,
variantMeta: [
{
id: 'A',
meta: {
headline: 'Headline from field A',
},
},
{
id: 'B',
meta: {
headline: 'Headline from field B',
},
},
],
},
];

const editorialAbTestBucket = 'B'; // lets pretend this was from data.config.serverSideABTests.editorialAbTest;
const cleanedEdAbTests = mockedEditorialAbTests.reduce(
(cleanedTests: EditorialAbTest[], test) => {
const { variantMeta, ...restOfTest } = test;

const newTest = {
...restOfTest,
variantMeta: variantMeta.filter(
(variant) => variant.id === editorialAbTestBucket,
),
};

return [...cleanedTests, newTest];
},
[],
);

const activeEditorialAbTests = cleanedEdAbTests.filter(
(test) =>
!test.hasManuallyEndedOnThisTrail &&
(!test.expiryDate || test.expiryDate > Date.now()),
);

const collections = enhanceCollections({
collections: data.pressedPage.collections,
Expand Down Expand Up @@ -54,6 +142,7 @@ const enhanceFront = (body: unknown): Front => {
deeplyRead: data.deeplyRead?.map((trail) => decideTrail(trail)),
canonicalUrl: data.canonicalUrl,
serverTime,
editorialAbTests: activeEditorialAbTests,
};
};

Expand Down
9 changes: 9 additions & 0 deletions dotcom-rendering/src/types/front.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface Front {
webURL: string;
guardianBaseURL: string;
serverTime?: number;
editorialAbTests?: EditorialAbTest[];
}

interface PressedPage {
Expand Down Expand Up @@ -188,3 +189,11 @@ export type EditorialTest = {
frontsThisTestCanRunOn: string[];
hasManuallyEndedOnThisTrail: boolean;
};

export type EditorialAbTest = {
testUuid: string;
expiryDate?: number;
variantMeta: VariantMeta[];
frontsThisTestCanRunOn: string[];
hasManuallyEndedOnThisTrail: boolean;
};
Loading