diff --git a/dotcom-rendering/src/components/FrontPage.tsx b/dotcom-rendering/src/components/FrontPage.tsx index c7e9aa095f3..6e98976db7b 100644 --- a/dotcom-rendering/src/components/FrontPage.tsx +++ b/dotcom-rendering/src/components/FrontPage.tsx @@ -79,6 +79,7 @@ export const FrontPage = ({ front, NAV }: Props) => { diff --git a/dotcom-rendering/src/components/SetABTests.island.tsx b/dotcom-rendering/src/components/SetABTests.island.tsx index 480188fc896..47569faa840 100644 --- a/dotcom-rendering/src/components/SetABTests.island.tsx +++ b/dotcom-rendering/src/components/SetABTests.island.tsx @@ -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; + editorialAbTests?: EditorialAbTest[]; }; const errorReporter = (e: unknown) => @@ -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>>(); @@ -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) { diff --git a/dotcom-rendering/src/experiments/lib/ab-tests.ts b/dotcom-rendering/src/experiments/lib/ab-tests.ts index e8154630036..4d0ac092ff1 100644 --- a/dotcom-rendering/src/experiments/lib/ab-tests.ts +++ b/dotcom-rendering/src/experiments/lib/ab-tests.ts @@ -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'; export interface ABTestAPI { getParticipations: () => ABParticipations; @@ -28,7 +29,9 @@ type OphanRecordFunction = (send: Record) => void; type ErrorReporter = (e: unknown) => void; -type ABTestsConfig = +type ABTestsConfig = { + editorialAbTests?: EditorialAbTest[]; +} & ( | { isServer: true; serverSideABTests: Record; @@ -36,7 +39,8 @@ type ABTestsConfig = | { isServer: false; serverSideABTests?: never; - }; + } +); /** * generate an A/B event for Ophan @@ -52,8 +56,15 @@ const makeABEvent = (variantName: string, complete: boolean): OphanABEvent => { 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 { @@ -79,9 +90,25 @@ export class ABTests implements ABTestAPI { ): void { ophanRecord({ abTestRegister: this.buildOphanPayload(errorReporter), + editorialAbTestRegister: this.buildOphanEditorialPayload(), }); } + private buildOphanEditorialPayload(): OphanABPayload { + return ( + this.editorialParticipations?.reduce( + (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 diff --git a/dotcom-rendering/src/frontend/schemas/feFront.json b/dotcom-rendering/src/frontend/schemas/feFront.json index 635b4daaed9..6602bfe0bdb 100644 --- a/dotcom-rendering/src/frontend/schemas/feFront.json +++ b/dotcom-rendering/src/frontend/schemas/feFront.json @@ -4459,4 +4459,4 @@ } }, "$schema": "http://json-schema.org/draft-07/schema#" -} \ No newline at end of file +} diff --git a/dotcom-rendering/src/frontend/schemas/feTagPage.json b/dotcom-rendering/src/frontend/schemas/feTagPage.json index 5d960526579..8edae4318b9 100644 --- a/dotcom-rendering/src/frontend/schemas/feTagPage.json +++ b/dotcom-rendering/src/frontend/schemas/feTagPage.json @@ -2149,4 +2149,4 @@ } }, "$schema": "http://json-schema.org/draft-07/schema#" -} \ No newline at end of file +} diff --git a/dotcom-rendering/src/server/handler.front.web.ts b/dotcom-rendering/src/server/handler.front.web.ts index 6e94ff02f33..82a2c747319 100644 --- a/dotcom-rendering/src/server/handler.front.web.ts +++ b/dotcom-rendering/src/server/handler.front.web.ts @@ -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'; @@ -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, @@ -54,6 +142,7 @@ const enhanceFront = (body: unknown): Front => { deeplyRead: data.deeplyRead?.map((trail) => decideTrail(trail)), canonicalUrl: data.canonicalUrl, serverTime, + editorialAbTests: activeEditorialAbTests, }; }; diff --git a/dotcom-rendering/src/types/front.ts b/dotcom-rendering/src/types/front.ts index d69e654cd0c..63d886632c4 100644 --- a/dotcom-rendering/src/types/front.ts +++ b/dotcom-rendering/src/types/front.ts @@ -39,6 +39,7 @@ export interface Front { webURL: string; guardianBaseURL: string; serverTime?: number; + editorialAbTests?: EditorialAbTest[]; } interface PressedPage { @@ -188,3 +189,11 @@ export type EditorialTest = { frontsThisTestCanRunOn: string[]; hasManuallyEndedOnThisTrail: boolean; }; + +export type EditorialAbTest = { + testUuid: string; + expiryDate?: number; + variantMeta: VariantMeta[]; + frontsThisTestCanRunOn: string[]; + hasManuallyEndedOnThisTrail: boolean; +};