From bc15a0e6ff2e115a4bab99a03c4ba4570bf6c343 Mon Sep 17 00:00:00 2001 From: Simon Byford Date: Thu, 30 Jul 2026 12:06:44 +0100 Subject: [PATCH 1/9] `make gen-schemas` --- dotcom-rendering/src/frontend/schemas/feFront.json | 2 +- dotcom-rendering/src/frontend/schemas/feTagPage.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 +} From 9af1bcbee974d7dada5f45275f8ded9022288f22 Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Thu, 30 Jul 2026 17:29:39 +0100 Subject: [PATCH 2/9] Add editorialAbTest as an optional field on Front model --- dotcom-rendering/src/types/front.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/dotcom-rendering/src/types/front.ts b/dotcom-rendering/src/types/front.ts index d69e654cd0c..7a809594c55 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,10 @@ export type EditorialTest = { frontsThisTestCanRunOn: string[]; hasManuallyEndedOnThisTrail: boolean; }; + +export type EditorialAbTest = { + testUuid: string; + expiryDate?: number; + frontsThisTestCanRunOn: string[]; + hasManuallyEndedOnThisTrail: boolean; +}; From d0d31ad213f97bef65a4a4023fd935d95bca49f6 Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Thu, 30 Jul 2026 17:30:05 +0100 Subject: [PATCH 3/9] Hardcode mocked editorial ab tests onto Front --- .../src/server/handler.front.web.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/dotcom-rendering/src/server/handler.front.web.ts b/dotcom-rendering/src/server/handler.front.web.ts index 6e94ff02f33..4c33963a64c 100644 --- a/dotcom-rendering/src/server/handler.front.web.ts +++ b/dotcom-rendering/src/server/handler.front.web.ts @@ -23,7 +23,26 @@ const enhanceFront = (body: unknown): Front => { const data: FEFront = validateAsFEFront(body); const serverTime = Date.now(); - + const mockedEditorialAbTests = [ + { + testUuid: '123', + expiryDate: Date.now() - 10 * 60 * 1000, // ten mins ago, + frontsThisTestCanRunOn: ['US'], + hasManuallyEndedOnThisTrail: false, + }, + { + testUuid: '456', + expiryDate: Date.now() + 10 * 60 * 1000, // ten mins in the future, + frontsThisTestCanRunOn: ['UK'], + hasManuallyEndedOnThisTrail: false, + }, + { + testUuid: '789', + expiryDate: Date.now() + 60 * 60 * 1000, // an hour in the future, + frontsThisTestCanRunOn: ['US'], + hasManuallyEndedOnThisTrail: true, + }, + ]; const collections = enhanceCollections({ collections: data.pressedPage.collections, editionId: data.editionId, @@ -54,6 +73,7 @@ const enhanceFront = (body: unknown): Front => { deeplyRead: data.deeplyRead?.map((trail) => decideTrail(trail)), canonicalUrl: data.canonicalUrl, serverTime, + editorialAbTests: mockedEditorialAbTests, }; }; From f5e46ee597a5363e653d04ce6e771d186fbd8648 Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Thu, 30 Jul 2026 17:36:15 +0100 Subject: [PATCH 4/9] filter editorial ab tests so only active tests reach the client --- dotcom-rendering/src/server/handler.front.web.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/dotcom-rendering/src/server/handler.front.web.ts b/dotcom-rendering/src/server/handler.front.web.ts index 4c33963a64c..1e6c3ee4e43 100644 --- a/dotcom-rendering/src/server/handler.front.web.ts +++ b/dotcom-rendering/src/server/handler.front.web.ts @@ -43,6 +43,13 @@ const enhanceFront = (body: unknown): Front => { hasManuallyEndedOnThisTrail: true, }, ]; + + const activeEditorialAbTests = mockedEditorialAbTests.filter( + (test) => + !test.hasManuallyEndedOnThisTrail && + (!test.expiryDate || test.expiryDate > Date.now()), + ); + const collections = enhanceCollections({ collections: data.pressedPage.collections, editionId: data.editionId, @@ -73,7 +80,7 @@ const enhanceFront = (body: unknown): Front => { deeplyRead: data.deeplyRead?.map((trail) => decideTrail(trail)), canonicalUrl: data.canonicalUrl, serverTime, - editorialAbTests: mockedEditorialAbTests, + editorialAbTests: activeEditorialAbTests, }; }; From c98f0c9488eefc4f6072412d9d73739150c431f2 Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Thu, 30 Jul 2026 17:37:36 +0100 Subject: [PATCH 5/9] Add editorial ab tests to the ab test api via the set ab test island in fronts and dispatch to ophan in a seperate register --- dotcom-rendering/src/components/FrontPage.tsx | 1 + .../src/components/SetABTests.island.tsx | 9 +++-- .../src/experiments/lib/ab-tests.ts | 33 +++++++++++++++++-- 3 files changed, 37 insertions(+), 6 deletions(-) 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..a713f498fcd 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) => { + eventLog[test.testUuid] = makeABEvent( + 'editorial test', + false, + ); + return eventLog; + }, + {}, + ) ?? {} + ); + } + private shouldReportToOphan(testId: string): boolean { const activeTest = activeABtests.find(({ name }) => name === testId); return activeTest?.shouldReportToOphan From a3c1f092c7853b46b45c35a3e92feb85718c3d5f Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Thu, 30 Jul 2026 17:39:35 +0100 Subject: [PATCH 6/9] Add implementation note --- dotcom-rendering/src/server/handler.front.web.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dotcom-rendering/src/server/handler.front.web.ts b/dotcom-rendering/src/server/handler.front.web.ts index 1e6c3ee4e43..2e4598c182c 100644 --- a/dotcom-rendering/src/server/handler.front.web.ts +++ b/dotcom-rendering/src/server/handler.front.web.ts @@ -23,6 +23,9 @@ 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 = [ { testUuid: '123', From d1ad39f7b0727344850a95f1407b9c8be3bb0bcf Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Thu, 30 Jul 2026 17:59:03 +0100 Subject: [PATCH 7/9] Make variant id a string # Conflicts: # dotcom-rendering/src/types/front.ts --- dotcom-rendering/src/types/front.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/dotcom-rendering/src/types/front.ts b/dotcom-rendering/src/types/front.ts index 7a809594c55..63d886632c4 100644 --- a/dotcom-rendering/src/types/front.ts +++ b/dotcom-rendering/src/types/front.ts @@ -193,6 +193,7 @@ export type EditorialTest = { export type EditorialAbTest = { testUuid: string; expiryDate?: number; + variantMeta: VariantMeta[]; frontsThisTestCanRunOn: string[]; hasManuallyEndedOnThisTrail: boolean; }; From 474144f97b3368bc9133a160948654b8b7bdef86 Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Thu, 30 Jul 2026 17:59:27 +0100 Subject: [PATCH 8/9] Add variant meta for the relevant bucket for ophan use --- .../src/server/handler.front.web.ts | 65 ++++++++++++++++++- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/dotcom-rendering/src/server/handler.front.web.ts b/dotcom-rendering/src/server/handler.front.web.ts index 2e4598c182c..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'; @@ -26,28 +26,87 @@ const enhanceFront = (body: unknown): Front => { /** * 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 = [ + 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 activeEditorialAbTests = mockedEditorialAbTests.filter( + 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()), From 59cc06701d9cedeca884f57c5c9272eb1b684cd4 Mon Sep 17 00:00:00 2001 From: Anna Beddow Date: Thu, 30 Jul 2026 18:14:31 +0100 Subject: [PATCH 9/9] Pass through the variant id so ophan knows what participation group we're in --- dotcom-rendering/src/experiments/lib/ab-tests.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dotcom-rendering/src/experiments/lib/ab-tests.ts b/dotcom-rendering/src/experiments/lib/ab-tests.ts index a713f498fcd..4d0ac092ff1 100644 --- a/dotcom-rendering/src/experiments/lib/ab-tests.ts +++ b/dotcom-rendering/src/experiments/lib/ab-tests.ts @@ -98,10 +98,10 @@ export class ABTests implements ABTestAPI { return ( this.editorialParticipations?.reduce( (eventLog, test) => { - eventLog[test.testUuid] = makeABEvent( - 'editorial test', - false, - ); + const variantId = test.variantMeta[0]?.id; + + if (!variantId) return eventLog; + eventLog[test.testUuid] = makeABEvent(variantId, false); return eventLog; }, {},