Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ab-testing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,5 @@ The algorithm allocates tests available MVT IDs based on the audience size and s
However, the allocation is completely separate for each audience space, so if you have a test in space `A` and move it to space `B`, it will be allocated different MVT IDs.

The state of the AB tests is stored in Fastly dictionaries, which are updated when the `deploy` task is run. Logic in fastly VCL will then use these dictionaries to determine which users are in which test groups and set appropriate headers and/or cookies.

See the [fastly-edge-cache documentation](https://github.com/guardian/fastly-edge-cache/blob/main/theguardiancom/docs/ab-testing.md) for even more details.
56 changes: 29 additions & 27 deletions ab-testing/frontend/src/lib/components/AudienceBreakdown.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@

const BAR_HEIGHT = 40;

// Account for legend bar and vertical padding in chart height
const chartHeight = tests.length * BAR_HEIGHT + BAR_HEIGHT + 16;
const BAR_MARGIN_X = 0.1;
const BAR_MARGIN_Y = 2;

const testSpaces = ['A', 'B', 'C'];

const chartHeight = testSpaces.length * BAR_HEIGHT + BAR_HEIGHT + 16;

const testsBySpace = testSpaces.map((space) => {
if (space === 'A') {
return tests.filter(
Expand All @@ -33,32 +35,32 @@
});

function getBars(testList: ABTest[], rowPosition: number) {
return testList.reduce<Array<ABTestBarData>>(
(barsList, test, index) => {
const previousBar = barsList.slice(-1)[0];
const offset: number = Number(previousBar?.width ?? 0);
const rowYLevel = index + rowPosition;
const testSize = getSize(test);

return [
...barsList,
{
x: offset,
y: rowYLevel * BAR_HEIGHT + BAR_HEIGHT,
width: testSize,
name: test.name,
segments: `${offset}% to ${offset + testSize}%`,
},
];
},
[],
);
return testList.reduce<Array<ABTestBarData>>((barsList, test) => {
const previousBarsWidth = barsList.reduce(
(acc, bar) => acc + bar.width,
0,
);
const offset: number = Number(previousBarsWidth);
const rowYLevel = rowPosition;
const testSize = getSize(test);

return [
...barsList,
{
x: offset,
y: rowYLevel * BAR_HEIGHT + BAR_HEIGHT,
width: testSize,
name: test.name,
segments: `${offset}% to ${offset + testSize}%`,
},
];
}, []);
}

function getAllRows(testsBySpace: ABTest[][]) {
return testsBySpace.reduce<Array<ABTestBarData>>(
(barsList, testsInSpace) => {
return [...barsList, ...getBars(testsInSpace, barsList.length)];
(barsList, testsInSpace, i) => {
return [...barsList, ...getBars(testsInSpace, i)];
},
[],
);
Expand Down Expand Up @@ -86,13 +88,13 @@
</svg>
{#each getAllRows(testsBySpace) as bar}
<svg
x={`${bar.x}%`}
x={`${bar.x + BAR_MARGIN_X}%`}
y={bar.y}
width={`${bar.width}%`}
width={`${bar.width - BAR_MARGIN_X * 2}%`}
height={BAR_HEIGHT}
>
<g class="bar">
<rect height={BAR_HEIGHT} width="100%" rx="4" />
<rect height={BAR_HEIGHT - BAR_MARGIN_Y} width="100%" rx="4" />
<text class="name" x="50%" y="50%">{bar.name}</text>
<text class="segments" x="50%" y="50%">{bar.segments}</text>
</g>
Expand Down
59 changes: 51 additions & 8 deletions ab-testing/frontend/src/lib/components/TableFixed.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
function daysToExpiry(expires: string) {
const today = new Date();
const expiresDate = new Date(expires);
const differenceInMilliseconds = expiresDate.getTime() - today.getTime();
const differenceInMilliseconds =
expiresDate.getTime() - today.getTime();
const differenceInDays =
differenceInMilliseconds / (1000 * 60 * 60 * 24);
return Math.floor(differenceInDays);
Expand All @@ -21,43 +22,69 @@

<section class="tests">
{#each tests as test}
{@const expired = daysToExpiry(test.expirationDate) < 0}
<table>
<colgroup>
<col span="1" style="width: 25%;" />
<col span="1" style="width: 10%;" />
<col span="1" style="width: 35%;" />
<col span="1" style="width: 10%;" />
<col span="1" style="width: 10%;" />
<col span="1" style="width: 10%;" />
</colgroup>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">State</th>
<th scope="col">Variants</th>
<th scope="col">Test Groups</th>
<th scope="col">Expires In</th>
<th scope="col">Audience</th>
<th scope="col">Offset</th>
<th scope="col">Ophan</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row" class="test-name">{test.name}</th>
<td>{test.status}</td>
<th scope="row" class="test-name"
>{test.name} ({test.type})</th
>
<td
class="status"
class:off={test.status === 'OFF'}
class:expired
>
{#if expired}
EXPIRED
{:else}
{test.status}
{/if}
</td>
<td>
<TestVariants
size={test.audienceSize * 100}
testName={test.name}
testGroups={test.groups}
/>
</td>
<td>{daysToExpiry(test.expirationDate)} days</td>
<td class:expired
>{daysToExpiry(test.expirationDate)} days</td
>
<td>{test.audienceSize * 100}%</td>
<td>0</td>
<td><OphanLink testName={test.name} /></td>
</tr>
<tr>
<th scope="row">Description</th>
<td colspan="6">{test.description}</td>
<td colspan="5">{test.description}</td>
</tr>
</tbody>
</table>
{/each}
</section>

<style>
:root {
--ok-green: #00823b;
--error-red: #d5281b;
}
.tests {
border: 1px solid var(--border-grey);
padding: 8px;
Expand Down Expand Up @@ -87,4 +114,20 @@
.test-name {
font-weight: 100;
}

.status {
text-transform: uppercase;
font-weight: bold;

color: var(--ok-green);

&.off {
color: #767676;
}
}

.expired {
color: var(--error-red);
font-weight: bold;
}
</style>
22 changes: 15 additions & 7 deletions ab-testing/frontend/src/lib/components/TestVariants.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,29 @@
interface Props {
testName: string;
testGroups: string[];
size: number;
}

const { testName, testGroups }: Props = $props();
const { testName, testGroups, size }: Props = $props();

const formatter = new Intl.NumberFormat('en-US', {
style: 'percent',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
</script>

<div>
<ul>
{#each testGroups as group}
{#each testGroups as group, i}
<li>
<a
href={`http://www.theguardian.com/uk#ab-${testName}=${group}`}
>
{group}
</a>
href={`https://www.theguardian.com/ab-tests/opt/in/${testName}:${group}`}
>
{group} ({formatter.format(
((1 / testGroups.length) * size) / 100,
)})
</a>{#if i < testGroups.length - 1}&nbsp;|&nbsp;{/if}
</li>
{/each}
</ul>
Expand All @@ -28,7 +37,6 @@
list-style: none;
display: flex;
flex-direction: row;
justify-content: space-between;
flex-wrap: wrap;
}
</style>
37 changes: 25 additions & 12 deletions ab-testing/frontend/src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,37 @@
import { ABTests } from '../../../abTest';
import Table from '$lib/components/TableFixed.svelte';
import AudienceBreakdown from '$lib/components/AudienceBreakdown.svelte';

const clientSideTests = ABTests.filter((test) => test.type === 'client');
const serverSideTests = ABTests.filter((test) => test.type === 'server');
</script>

<h1 class="headline">A/B Tests</h1>
<section>
<p>This page provides an overview of currently running A/B tests on theguardian.com. Please note that the audience segment allocations displayed for non-overlapping tests may not correspond to the actual allocation of MVT IDs, but simply represents how much of the audience is included in each test.</p>
</section>
<section>
<h2 class="sub-headline">Client-side Tests</h2>
<AudienceBreakdown tests={clientSideTests} />
<Table tests={clientSideTests} />
<p>
This page provides an overview of currently running A/B tests on
theguardian.com. Please note that the audience segment allocations
displayed for non-overlapping tests may not correspond to the actual
allocation of MVT IDs, but simply represents how much of the audience is
included in each test.
</p>
<p>
AB tests are defined in <a
href="https://github.com/guardian/dotcom-rendering/blob/main/ab-testing/abTest.ts"
>guardian/dotcom-rendering</a
>
</p>
<p>
Use the test group links in the table to opt in to those test groups,
this will override any cookie based test assignment, and you will only
be in that test until you opt out.
</p>
<p>
<a href="https://www.theguardian.com/ab-tests/opt/out"
>Use this link to opt out of any tests</a
>
</p>
</section>
<section>
<h2 class="sub-headline">Server-side Tests</h2>
<AudienceBreakdown tests={serverSideTests} />
<Table tests={serverSideTests} />
<AudienceBreakdown tests={ABTests} />
<Table tests={ABTests} />
</section>

<style>
Expand Down
15 changes: 15 additions & 0 deletions dotcom-rendering/docs/development/ab-testing-in-dcr.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ When the config is merged, the A/B test will be automatically deployed and be av

Ab test on/off state is controlled only by the config. Expired tests will cause the ab testing validation to fail, they will also not be served. In effect expired tests are turned off "automatically", but their config needs to be cleaned up.

The test will appear in https://frontend.gutools.co.uk/analytics/ab-testing once the config is deployed.

## Putting code changes behind an A/B test (group)

### Use in Components
Expand Down Expand Up @@ -62,6 +64,19 @@ The ab test API is also available on the window object as `window.guardian.modul

Server side tests are also available in the CAPI object e.g. `CAPIArticle.config.serverSideABTests`.

## Forcing yourself into a test

Use the opt-in and opt-out URL fragments to force yourself into or out of a test.

When opted-in, the test will override any mvt based assignment and you'll only be in the opted-in test group.

When opted-out, you'll return to random/mvt based assignment.

These links are also in the [frontend admin](https://frontend.gutools.co.uk/analytics/ab-testing).

- Opt-in Example: `https://theguardian.com/ab-tests/opt/in/commercial-test-example:variant`
- Opt-out: `https://theguardian.com/ab-tests/opt/out`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would we need to specify the test we are opting out of?

Suggested change
- Opt-out: `https://theguardian.com/ab-tests/opt/out`
- Opt-out: `https://theguardian.com/ab-tests/opt/out/commercial-test-example:variant`

Or would this opt out of all tests?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can only opt into one test at a time, so specifying for the opt out isn't necessary

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh interesting! Didn't realise that


# Legacy A/B testing in DCR

> [!WARNING]
Expand Down
Loading
Loading