Skip to content

Commit 39bbd25

Browse files
authored
GOTH-580 A/B Test Infrastructure and first A/B test (#275)
When a user goes to the page they should get randomly assigned to either variant 0 or 1. Sets a cookie "_experiment_no-duplicates-in-river" with a value of either 0 or 1. This value should be the same upon returning to the page (the cookie lasts for 30 days and this timer is refreshed on new visits). The experiment name ("no-duplicates-in-river") and variant ("0" or "1") are attached to all GA events as 'experimentName' and 'experimentVariant' respectively. If the variant is 0, the home page remains unchanged. If the variant is 1, stories in the latest news section at the bottom of the homepage aree deduplicated from the homepage topper. In other words, anything that appears in the "Latest" section in the homepage topper shouldn't also appear in the "Latest" river. Because the latest section in the homepage topper is also deduplicated from the curated featured articles, there's also of a special case. If either or both of the curated articles in the homepage topper are among the top 6 latest articles, they'll also be deduplicated from the river at the bottom of the page.
1 parent 2ded18e commit 39bbd25

File tree

11 files changed

+251
-6
lines changed

11 files changed

+251
-6
lines changed

composables/types/Experiment.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export default interface Experiment {
2+
name: string;
3+
maxAgeSeconds?: number;
4+
variants: {weight:number}[];
5+
}

cypress/support/commands.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ declare module 'Cypress' {
4343
}
4444

4545
Cypress.Commands.add('loadGlobalFixtures', () => {
46+
cy.setCookie('_experiment_no-duplicates-in-river', '0', {path: '/'})
4647
cy.intercept('/api/v2/system_messages/*', {fixture: 'aviary/system_messages_empty.json'}).as('systemMessages')
4748
cy.intercept('/api/v2/sitewide_components/*', {fixture: 'aviary/sitewide_components.json'}).as('sitewideComponents')
4849
cy.intercept('/api/v2/navigation/*', {fixture: 'aviary/navigation.json'}).as('navigation')

experiments/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import noDuplicatesInRiver from './no-duplicates-in-river/index'
2+
3+
export default [
4+
noDuplicatesInRiver
5+
]
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export default {
2+
// A helper exp-{name}-{var} class will be added to the root element
3+
name: 'no-duplicates-in-river',
4+
5+
maxAgeSeconds: 60 * 60 * 24 * 30, //30 days
6+
7+
variants: [
8+
{ weight: 1 }, // 0: Original
9+
{ weight: 1 } // 1: Remove duplicate stories from the latest news river
10+
]
11+
}

layouts/default.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const marketingBannerData = useMarketingBannerData()
99
1010
const config = useRuntimeConfig()
1111
const route = useRoute()
12-
const { $htlbid, $analytics } = useNuxtApp()
12+
const { $htlbid, $analytics, $features } = useNuxtApp()
1313
1414
const navigationState = useNavigation()
1515
const navigationPromise = findNavigation().then(({ data }) => {

pages/index.vue

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,22 @@ import { ArticlePage } from '~~/composables/types/Page'
44
import { computed, ref, nextTick } from 'vue'
55
import { onBeforeRouteUpdate } from 'vue-router'
66
7+
const { $features } = useNuxtApp()
8+
const possibleDuplicateCount = ref(6) // from the topper
9+
const actualDuplicateCount = ref(4)
710
const riverStoryCount = ref(6)
811
const riverAdOffset = ref(2)
912
const riverAdRepeatRate = ref(6)
1013
const riverContainer = ref('#latest')
1114
15+
let findArticleLimit
16+
if ($features.enabled['experiment-deduplicate-river']) {
17+
findArticleLimit = riverStoryCount.value + possibleDuplicateCount.value
18+
} else {
19+
findArticleLimit = riverStoryCount.value
20+
}
1221
const articlesPromise = findArticlePages({
13-
limit: riverStoryCount.value,
22+
limit: findArticleLimit,
1423
sponsored_content: false,
1524
}).then(({ data }) => normalizeFindArticlePagesResponse(data))
1625
@@ -35,6 +44,18 @@ const latestArticles = ref([...articles])
3544
// the home page featured article should display only the first and second story in the home page content collection
3645
const featuredArticles = homePageCollections?.[0].data.map(normalizeArticlePage)
3746
47+
actualDuplicateCount.value = 4
48+
const firstFour = articles.slice(0,6).map(article => article.uuid)
49+
if (firstFour.includes(featuredArticles[0].uuid)) { actualDuplicateCount.value += 1 }
50+
if (firstFour.includes(featuredArticles[1].uuid)) { actualDuplicateCount.value += 1 }
51+
52+
const filteredLatestArticles = computed(() => {
53+
return latestArticles.value.slice(actualDuplicateCount.value)
54+
})
55+
56+
const riverArticles = $features.enabled['experiment-deduplicate-river'] ?
57+
filteredLatestArticles : latestArticles
58+
3859
usePreloadResponsiveImage(
3960
useImageUrl(featuredArticles?.[0]?.listingImage, {
4061
width: 700,
@@ -49,19 +70,26 @@ usePreloadResponsiveImage(
4970
)
5071
5172
const riverSegments = computed(() => {
52-
let riverCopy = latestArticles.value.slice()
73+
let riverCopy = riverArticles.value.slice()
5374
const segments = [] as ArticlePage[][]
54-
while (riverCopy.length) {
75+
while (riverCopy.length >= 6) {
5576
segments.push(riverCopy.splice(0, riverStoryCount.value))
5677
}
5778
return segments
5879
})
5980
6081
const loadMoreArticles = async () => {
82+
let loadMoreOffset
83+
if ($features.enabled['experiment-deduplicate-river']) {
84+
loadMoreOffset = latestArticles.value.length + actualDuplicateCount.value
85+
} else {
86+
loadMoreOffset = latestArticles.value.length
87+
}
88+
6189
const newArticles = await useLoadMoreArticles({
6290
sponsored_content: false,
6391
limit: riverStoryCount.value,
64-
offset: latestArticles.value.length,
92+
offset: loadMoreOffset,
6593
})
6694
latestArticles.value.push(...newArticles)
6795
await nextTick()

plugins/1.experiments.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import currentExperiments from "~/experiments";
2+
import Experiment from "~/composables/types/Experiment";
3+
import { get } from "cypress/types/lodash";
4+
5+
export default defineNuxtPlugin(() => {
6+
let activeVariant:number
7+
const defaultMaxAge = 60 * 60 * 24 * 30 // 30 days
8+
9+
const chooseWeightedRandom = (weights:number[]):number => {
10+
const sumOfWeights = weights.reduce(
11+
(sum, weight) => sum + weight,
12+
0)
13+
const choice = Math.random()
14+
let threshold = 0
15+
for (let i = 0; i < weights.length; i++) {
16+
threshold += weights[i]/sumOfWeights
17+
if (choice <= threshold) {
18+
return i
19+
}
20+
}
21+
}
22+
23+
const assignVariants = (experiments:Experiment[]):void => {
24+
experiments.forEach(experiment => {
25+
if (experiment === getCurrentExperiment() && typeof activeVariant === 'undefined') {
26+
activeVariant = readVariant(experiment) ?? chooseVariant(experiment)
27+
}
28+
})
29+
}
30+
31+
const chooseVariant = (experiment:Experiment):number => {
32+
return chooseWeightedRandom(experiment.variants.map(variant => variant.weight))
33+
}
34+
35+
const readVariant = (experiment:Experiment):number => {
36+
const cookie = useCookie(`_experiment_${experiment.name}`, { path: '/' })
37+
if (typeof cookie.value !== 'undefined') {
38+
return Number(cookie.value)
39+
}
40+
}
41+
42+
const saveVariant = (experiment:Experiment, variant:number):void => {
43+
const cookie = useCookie(
44+
`_experiment_${experiment.name}`,
45+
{
46+
path: '/',
47+
maxAge: experiment.maxAgeSeconds ?? defaultMaxAge,
48+
}
49+
)
50+
cookie.value = String(variant)
51+
}
52+
53+
const getCurrentExperiment = ():Experiment => {
54+
if (currentExperiments.length > 0) {
55+
return currentExperiments[0]
56+
} else {
57+
return undefined
58+
}
59+
}
60+
61+
assignVariants(currentExperiments)
62+
63+
if (!process.client) {
64+
saveVariant(getCurrentExperiment(), activeVariant)
65+
}
66+
67+
return {
68+
provide: {
69+
experiments: {
70+
current: getCurrentExperiment(),
71+
activeVariant
72+
}
73+
}
74+
}
75+
})

plugins/2.features.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
export default defineNuxtPlugin(() => {
2+
3+
// Add your features here
4+
const { $experiments } = useNuxtApp()
5+
6+
const features = {
7+
// Remove duplicate stories from the river on the home page
8+
// Part of an a/b test
9+
// - Matt Walsh
10+
// Experiment started: May 17 2023
11+
'experiment-deduplicate-river':
12+
$experiments.current?.name === 'no-duplicates-in-river' &&
13+
$experiments.activeVariant === 1
14+
}
15+
16+
const enabled = features || {}
17+
const disabled = {}
18+
for (const feature of Object.entries(enabled)) {
19+
disabled[feature[0]] = !feature[1]
20+
}
21+
const classes = Object.entries(enabled)
22+
.map(entry => (entry[1] ? `${entry[0]}-enabled` : `${entry[0]}-disabled`))
23+
24+
useHead({
25+
bodyAttrs: {
26+
class: classes.join(' ')
27+
},
28+
})
29+
30+
return {
31+
provide: {
32+
features: { enabled, disabled, classes }
33+
}
34+
}
35+
})
File renamed without changes.

plugins/analytics.client.ts renamed to plugins/4.analytics.client.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useMembershipStatus } from "~~/composables/states"
33
export default defineNuxtPlugin(() => {
44
const config = useRuntimeConfig()
55
const membershipStatus = useMembershipStatus()
6+
const { $experiments } = useNuxtApp()
67

78
window.dataLayer = window.dataLayer || []
89
// init gtag function
@@ -13,7 +14,15 @@ export default defineNuxtPlugin(() => {
1314

1415
// event to use when sending gtag events
1516
const sendEvent = (name: string, params: Record<string, string>) => {
16-
gtag('event', name, params)
17+
if ($experiments.current) {
18+
gtag('event', name, {
19+
experimentName: $experiments.current.name,
20+
experimentVariant: $experiments.activeVariant,
21+
...params
22+
})
23+
} else {
24+
gtag('event', name, params)
25+
}
1726
}
1827
// gtag even for reporting on page views
1928
const sendPageView = (params: Record<string, string>) => {

0 commit comments

Comments
 (0)