Skip to content

Commit 90ffd24

Browse files
authored
feat: A/B test framework for content readability experiment (#59592)
1 parent 95c4cf2 commit 90ffd24

5 files changed

Lines changed: 147 additions & 1 deletion

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { useLayoutEffect, useState } from 'react'
2+
import { useShouldShowExperiment } from '@/events/components/experiments/useShouldShowExperiment'
3+
import { EXPERIMENTS } from '@/events/components/experiments/experiments'
4+
5+
const EXPERIMENT_KEY = EXPERIMENTS.readability_copilot.key
6+
7+
// Swaps visibility of .exp-control / .exp-treatment divs within the article
8+
// body based on the user's experiment group. Both variants are rendered
9+
// server-side (and cached by Fastly). Treatment divs must have the `hidden`
10+
// and `data-nosnippet` attributes in the authored HTML so control content is
11+
// always the safe fallback and crawlers ignore the treatment variant.
12+
export function ExperimentContentSwap({ containerRef }: { containerRef: string }) {
13+
const [hasExperimentDivs, setHasExperimentDivs] = useState(false)
14+
15+
// Check once on mount whether this page has any experiment markup.
16+
// If not, skip the experiment hook entirely to avoid unnecessary work.
17+
useLayoutEffect(() => {
18+
const container = document.querySelector(containerRef)
19+
if (container?.querySelector(`[data-experiment="${EXPERIMENT_KEY}"]`)) {
20+
setHasExperimentDivs(true)
21+
}
22+
}, [containerRef])
23+
24+
if (!hasExperimentDivs) return null
25+
26+
return <ExperimentSwapper containerRef={containerRef} />
27+
}
28+
29+
// Separated so the experiment hook only runs when experiment divs are present.
30+
function ExperimentSwapper({ containerRef }: { containerRef: string }) {
31+
const { showExperiment, experimentLoading } = useShouldShowExperiment(
32+
EXPERIMENTS.readability_copilot,
33+
)
34+
35+
// useLayoutEffect fires synchronously after DOM mutations but before
36+
// the browser paints, minimizing the flash of control content for
37+
// treatment users.
38+
useLayoutEffect(() => {
39+
if (experimentLoading) return
40+
41+
const container = document.querySelector(containerRef)
42+
if (!container) return
43+
44+
const selector = `[data-experiment="${EXPERIMENT_KEY}"]`
45+
const controlDivs = container.querySelectorAll<HTMLElement>(`.exp-control${selector}`)
46+
const treatmentDivs = container.querySelectorAll<HTMLElement>(`.exp-treatment${selector}`)
47+
48+
if (showExperiment) {
49+
for (const div of controlDivs) div.hidden = true
50+
for (const div of treatmentDivs) div.hidden = false
51+
} else {
52+
for (const div of controlDivs) div.hidden = false
53+
for (const div of treatmentDivs) div.hidden = true
54+
}
55+
}, [showExperiment, experimentLoading, containerRef])
56+
57+
return null
58+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Content A/B testing
2+
3+
This guide explains how to set up an A/B test on article content using the experiment framework. Both content variants live in the same Markdown file and are served in a single Fastly-cached response. Client-side JavaScript swaps which variant is visible based on the user's experiment group.
4+
5+
For background on A/B testing at GitHub Docs, see [the A/B testing guide](https://github.com/github/docs-team/blob/main/analytics/ab-test.md).
6+
7+
> [!IMPORTANT]
8+
> Only one experiment can have `includeVariationInContext: true` at a time, because `experiment_variation` is a single key in the event context. Multiple experiments can run concurrently if the others use `sendExperimentSuccess` for tracking instead. See the [experiments README](./README.md) for details.
9+
10+
## Authoring an experiment article
11+
12+
Wrap the control (original) and treatment (rewritten) content in HTML divs. Both variants render as normal Markdown inside the divs.
13+
14+
```markdown
15+
---
16+
title: Your article title
17+
---
18+
19+
<div class="exp-control" data-experiment="readability_copilot">
20+
21+
Original article body here. Markdown renders normally inside the div.
22+
23+
Links, lists, code blocks, and all other Markdown features work as usual.
24+
25+
</div>
26+
<div class="exp-treatment" data-experiment="readability_copilot" hidden data-nosnippet>
27+
28+
Rewritten article body here. This is the treatment variant.
29+
30+
</div>
31+
```
32+
33+
### Rules
34+
35+
* **Blank lines required**: Leave a blank line after the opening `<div>` and before the closing `</div>` so Markdown renders correctly inside the div.
36+
* **`data-experiment` attribute**: Must match the experiment key registered in `experiments.ts` (currently `readability_copilot`).
37+
* **`hidden` attribute**: Always add `hidden` to the treatment div. This ensures the control is shown by default (safe fallback if JavaScript fails).
38+
* **`data-nosnippet` attribute**: Always add `data-nosnippet` to the treatment div. This tells search engines to ignore the treatment text.
39+
* **Multiple pairs**: You can have multiple control/treatment pairs in one article if only some sections differ. Each pair must have the matching `data-experiment`, class, and attributes.
40+
41+
## Previewing the treatment
42+
43+
* **Staff cookie**: If you have the `staffonly` cookie set, you will always see the treatment.
44+
* **URL parameter**: Add `?feature=readability` to any article URL to force the treatment variant.
45+
* **Console override**: In the browser console, run:
46+
```javascript
47+
window.overrideControlGroup('readability_copilot', 'treatment')
48+
```
49+
Reload the page to see the treatment. Use `'control'` to switch back.
50+
51+
## How tracking works
52+
53+
When `includeVariationInContext` is `true` (which it is for this experiment), **every** analytics event on the page automatically includes `experiment_variation: "control"` or `"treatment"` in its context. This means:
54+
55+
* Page view events → measure impressions per variant
56+
* Link click events → measure CTA clicks per variant
57+
* Exit events → measure scroll depth (`exit_scroll_length`), time on page (`exit_visit_duration`), and scroll engagement (`exit_scroll_flip`) per variant
58+
* No extra tracking code is needed in the content
59+
60+
CTA links that point to external sites (like `github.com/features/copilot`) are already tracked by the existing link event system. The `link_samesite: false` flag identifies external (CTA) clicks.
61+
62+
To analyze additional event types (such as scroll depth or time on page), add queries to the dashboard—no code changes are needed.
63+
64+
## Analyzing results
65+
66+
Use the **[Docs Experiment Results dashboard](https://gh.io/docs-8c0c)** to track split verification, CTA click-through rates, sequential significance testing, and per-article breakdowns. The dashboard has parameters for experiment name, path product, and minimum detectable effect.
67+
68+
Dashboard source config: [`docs-team/analytics/dashboard-builder/experiment-results.config.ts`](https://github.com/github/docs-team/blob/main/analytics/dashboard-builder/experiment-results.config.ts)
69+
70+
## Ending the experiment
71+
72+
1. Set `isActive: false` in `src/events/components/experiments/experiments.ts`.
73+
2. Remove the experiment divs from the articles, keeping whichever variant won.
74+
3. Open a PR documenting the results.

src/events/components/experiments/experiment.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export function shouldShowExperiment(
2929
const experiments = getActiveExperiments('all')
3030
for (const experiment of experiments) {
3131
if (experiment.key === experimentKey) {
32+
// Respect isActive so flipping it to false actually stops the experiment
33+
if (!experiment.isActive) return false
3234
// If there is an override for the current session, use that
3335
if (controlGroupOverride[experiment.key]) {
3436
const controlGroup = getExperimentControlGroupFromSession(

src/events/components/experiments/experiments.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ type Experiment = {
1515
}
1616

1717
// Update this with the name of the experiment, e.g. | 'example_experiment'
18-
export type ExperimentNames = 'placeholder_experiment'
18+
export type ExperimentNames = 'placeholder_experiment' | 'readability_copilot'
1919

2020
export const EXPERIMENTS = {
2121
// Placeholder experiment to maintain type compatibility
@@ -29,6 +29,16 @@ export const EXPERIMENTS = {
2929
alwaysShowForStaff: false,
3030
turnOnWithURLParam: 'placeholder', // Placeholder URL param
3131
},
32+
readability_copilot: {
33+
key: 'readability_copilot',
34+
isActive: true,
35+
percentOfUsersToGetExperiment: 50,
36+
includeVariationInContext: true,
37+
limitToLanguages: ['en'],
38+
limitToVersions: [],
39+
alwaysShowForStaff: true,
40+
turnOnWithURLParam: 'readability',
41+
},
3242
/* Add new experiments here, example:
3343
'example_experiment': {
3444
key: 'example_experiment',

src/frame/components/article/ArticlePage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { LinkPreviewPopover } from '@/links/components/LinkPreviewPopover'
2424
import { UtmPreserver } from '@/frame/components/UtmPreserver'
2525
import { JourneyTrackCard, JourneyTrackNav } from '@/journeys/components'
2626
import { ViewMarkdownButton } from './ViewMarkdownButton'
27+
import { ExperimentContentSwap } from '@/events/components/experiments/ExperimentContentSwap'
2728

2829
const ClientSideRefresh = dynamic(() => import('@/frame/components/ClientSideRefresh'), {
2930
ssr: false,
@@ -95,6 +96,7 @@ export const ArticlePage = () => {
9596
)}
9697

9798
<MarkdownContent>{renderedPage}</MarkdownContent>
99+
<ExperimentContentSwap containerRef="#article-contents" />
98100
{effectiveDate && (
99101
<div className="mt-4" id="effectiveDate">
100102
Effective as of:{' '}

0 commit comments

Comments
 (0)