Skip to content

Commit e94a20e

Browse files
committed
Merge ab-test-support into main
2 parents 1e13c26 + 1003f98 commit e94a20e

9 files changed

Lines changed: 259 additions & 10 deletions

File tree

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,8 @@ pnpm-debug.log*
3434
# deno lock file (auto-generated)
3535
deno.lock
3636

37+
# Local Netlify folder
38+
.netlify
39+
3740
# vscode machine-specific settings
38-
.vscode/settings.json
41+
.vscode/

netlify.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[[edge_functions]]
2+
function = "cohort"
3+
path = "/*"

netlify/edge-functions/cohort.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { Context } from "https://edge.netlify.com";
2+
3+
const COOKIE_NAME = "aud_ab_id";
4+
const MAX_ID = 100000;
5+
const ONE_YEAR = 60 * 60 * 24 * 365;
6+
7+
export default async function cohort(request: Request, context: Context) {
8+
const cookies = request.headers.get("cookie") ?? "";
9+
const hasId = cookies
10+
.split(";")
11+
.some((c) => c.trim().startsWith(`${COOKIE_NAME}=`));
12+
13+
const response = await context.next();
14+
15+
if (!hasId) {
16+
const id = Math.floor(Math.random() * MAX_ID);
17+
response.headers.append(
18+
"set-cookie",
19+
`${COOKIE_NAME}=${id}; Path=/; Max-Age=${ONE_YEAR}; SameSite=Lax`,
20+
);
21+
}
22+
23+
return response;
24+
}

src/assets/data/experiments.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
export type Variant = {
2+
name: string;
3+
weight: number;
4+
};
5+
6+
export type Experiment = {
7+
name: string;
8+
variants: Variant[];
9+
enabled: boolean;
10+
};
11+
12+
/**
13+
* Register experiments here. Each experiment needs a unique name, two or more
14+
* weighted variants, and an `enabled` flag.
15+
*
16+
* Example 50/50 test:
17+
*
18+
* {
19+
* name: "hero-cta",
20+
* variants: [
21+
* { name: "control", weight: 50 },
22+
* { name: "variant-b", weight: 50 },
23+
* ],
24+
* enabled: true,
25+
* },
26+
*/
27+
export const experiments: Experiment[] = [
28+
{
29+
name: "nav-logo",
30+
variants: [
31+
{ name: "control", weight: 50 },
32+
{ name: "text-only", weight: 50 },
33+
],
34+
enabled: true,
35+
},
36+
];
37+
38+
export function getExperiment(name: string): Experiment | undefined {
39+
return experiments.find((e) => e.name === name);
40+
}

src/assets/js/matomoTracking.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { getAllAssignments, formatAssignments } from "../../utils/experiment";
2+
13
const getCookie = (name) => {
24
const value = `; ${document.cookie}`;
35
const parts = value.split(`; ${name}=`);
@@ -8,12 +10,15 @@ const branch = import.meta.env.BRANCH || "unknown-branch";
810

911
var _paq = (window._paq = window._paq || []);
1012
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
11-
_paq.push(['setCustomDimension', 1, branch]); // ab-branch
13+
const assignments = getAllAssignments();
14+
const assignmentStr = formatAssignments(assignments);
15+
_paq.push(["setCustomDimension", 1, assignmentStr || branch]); // ab-branch
16+
1217
_paq.push(["trackPageView"]);
1318
_paq.push(["enableLinkTracking"]);
1419

1520
// Tell Matomo to wait for cookie consent
16-
_paq.push(['requireCookieConsent']);
21+
_paq.push(["requireCookieConsent"]);
1722
(function () {
1823
var u = "https://matomo.audacityteam.org/";
1924
_paq.push(["setTrackerUrl", u + "matomo.php"]);
@@ -28,5 +33,4 @@ _paq.push(['requireCookieConsent']);
2833

2934
if (getCookie("audacity_consent") === "true") {
3035
_paq.push(["setCookieConsentGiven"]);
31-
}
32-
36+
}

src/components/navigation/NavigationReact.jsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import React, { useState } from "react";
22
import AudacityLogo from "../../assets/img/Audacity_Logo.svg";
3+
import { useExperiment } from "../../hooks/useExperiment";
34
import "@fontsource-variable/signika";
45
import "../../styles/fonts.css";
56

67
function NavigationReact(props) {
78
const { currentURL } = props;
89
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
10+
const { variant: navLogoVariant } = useExperiment("nav-logo");
911

1012
function getUrlPath(url) {
1113
const parts = url.split("/");
@@ -70,11 +72,13 @@ function NavigationReact(props) {
7072
<div className="flex h-14 items-center max-w-screen-2xl mx-auto px-4 md:px-6">
7173
<div className="flex-1">
7274
<a className="flex w-fit items-center gap-1 lg:gap-2" href="/">
73-
<img
74-
className="w-5 lg:w-6 h-full"
75-
src={AudacityLogo.src}
76-
alt="A yellow and orange waveform between the ears of a set of blue headphones"
77-
/>
75+
{navLogoVariant !== "text-only" && (
76+
<img
77+
className="w-5 lg:w-6 h-full"
78+
src={AudacityLogo.src}
79+
alt="A yellow and orange waveform between the ears of a set of blue headphones"
80+
/>
81+
)}
7882
<p className="signika text-blue-700 lg:text-lg font-medium lg:leading-none">
7983
Audacity
8084
</p>

src/hooks/useExperiment.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useState, useEffect } from "react";
2+
import { getExperiment } from "../assets/data/experiments";
3+
import { getAbId, getVariant } from "../utils/experiment";
4+
5+
type UseExperimentResult = {
6+
variant: string | null;
7+
isReady: boolean;
8+
};
9+
10+
export function useExperiment(experimentName: string): UseExperimentResult {
11+
const [result, setResult] = useState<UseExperimentResult>({
12+
variant: null,
13+
isReady: false,
14+
});
15+
16+
useEffect(() => {
17+
const experiment = getExperiment(experimentName);
18+
if (!experiment || !experiment.enabled) {
19+
setResult({ variant: null, isReady: true });
20+
return;
21+
}
22+
23+
const abId = getAbId();
24+
if (abId === null) {
25+
setResult({ variant: null, isReady: true });
26+
return;
27+
}
28+
29+
setResult({ variant: getVariant(experiment, abId), isReady: true });
30+
}, [experimentName]);
31+
32+
return result;
33+
}

src/utils/experiment.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { hashToSlot, getVariant } from "./experiment";
3+
import type { Experiment } from "../assets/data/experiments";
4+
5+
describe("hashToSlot", () => {
6+
test("returns a number between 0 and 99", () => {
7+
for (let id = 0; id < 1000; id++) {
8+
const slot = hashToSlot(id, "test-experiment");
9+
expect(slot).toBeGreaterThanOrEqual(0);
10+
expect(slot).toBeLessThan(100);
11+
}
12+
});
13+
14+
test("is deterministic for the same inputs", () => {
15+
const a = hashToSlot(42, "hero-cta");
16+
const b = hashToSlot(42, "hero-cta");
17+
expect(a).toBe(b);
18+
});
19+
20+
test("different experiment names produce different slots for same user", () => {
21+
expect(hashToSlot(12345, "experiment-a")).toBe(83);
22+
expect(hashToSlot(12345, "experiment-b")).toBe(82);
23+
});
24+
25+
test("different user IDs produce different slots for same experiment", () => {
26+
const slot1 = hashToSlot(1, "hero-cta");
27+
const slot2 = hashToSlot(2, "hero-cta");
28+
expect(slot1).not.toBe(slot2);
29+
});
30+
});
31+
32+
describe("getVariant", () => {
33+
const fiftyFifty: Experiment = {
34+
name: "test",
35+
variants: [
36+
{ name: "control", weight: 50 },
37+
{ name: "variant-b", weight: 50 },
38+
],
39+
enabled: true,
40+
};
41+
42+
test("always returns a valid variant name", () => {
43+
const validNames = fiftyFifty.variants.map((v) => v.name);
44+
for (let id = 0; id < 1000; id++) {
45+
const variant = getVariant(fiftyFifty, id);
46+
expect(validNames).toContain(variant);
47+
}
48+
});
49+
50+
test("roughly 50/50 distribution over many IDs", () => {
51+
const counts: Record<string, number> = { control: 0, "variant-b": 0 };
52+
const total = 10000;
53+
for (let id = 0; id < total; id++) {
54+
counts[getVariant(fiftyFifty, id)]++;
55+
}
56+
// Allow 10% tolerance
57+
expect(counts.control).toBeGreaterThan(total * 0.4);
58+
expect(counts.control).toBeLessThan(total * 0.6);
59+
expect(counts["variant-b"]).toBeGreaterThan(total * 0.4);
60+
expect(counts["variant-b"]).toBeLessThan(total * 0.6);
61+
});
62+
63+
test("respects unequal weights", () => {
64+
const experiment: Experiment = {
65+
name: "weighted",
66+
variants: [
67+
{ name: "a", weight: 80 },
68+
{ name: "b", weight: 20 },
69+
],
70+
enabled: true,
71+
};
72+
const counts: Record<string, number> = { a: 0, b: 0 };
73+
const total = 10000;
74+
for (let id = 0; id < total; id++) {
75+
counts[getVariant(experiment, id)]++;
76+
}
77+
// "a" should get ~80% (allow 10% tolerance)
78+
expect(counts.a).toBeGreaterThan(total * 0.7);
79+
expect(counts.a).toBeLessThan(total * 0.9);
80+
});
81+
82+
test("is deterministic", () => {
83+
const v1 = getVariant(fiftyFifty, 42);
84+
const v2 = getVariant(fiftyFifty, 42);
85+
expect(v1).toBe(v2);
86+
});
87+
});

src/utils/experiment.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { experiments, type Experiment } from "../assets/data/experiments";
2+
3+
const COOKIE_NAME = "aud_ab_id";
4+
5+
export function getAbId(): number | null {
6+
if (typeof document === "undefined") return null;
7+
const match = document.cookie
8+
.split(";")
9+
.map((c) => c.trim())
10+
.find((c) => c.startsWith(`${COOKIE_NAME}=`));
11+
if (!match) return null;
12+
const val = parseInt(match.split("=")[1], 10);
13+
return Number.isFinite(val) ? val : null;
14+
}
15+
16+
/** Deterministic hash (djb2) of abId + experiment name → 0‑99 */
17+
export function hashToSlot(abId: number, experimentName: string): number {
18+
const input = `${abId}:${experimentName}`;
19+
let hash = 5381;
20+
for (let i = 0; i < input.length; i++) {
21+
hash = ((hash << 5) + hash + input.charCodeAt(i)) | 0;
22+
}
23+
return Math.abs(hash) % 100;
24+
}
25+
26+
export function getVariant(experiment: Experiment, abId: number): string {
27+
const slot = hashToSlot(abId, experiment.name);
28+
let cumulative = 0;
29+
for (const v of experiment.variants) {
30+
cumulative += v.weight;
31+
if (slot < cumulative) return v.name;
32+
}
33+
return experiment.variants[experiment.variants.length - 1].name;
34+
}
35+
36+
export function getAllAssignments(): Record<string, string> {
37+
const abId = getAbId();
38+
if (abId === null) return {};
39+
const result: Record<string, string> = {};
40+
for (const exp of experiments) {
41+
if (!exp.enabled) continue;
42+
result[exp.name] = getVariant(exp, abId);
43+
}
44+
return result;
45+
}
46+
47+
export function formatAssignments(assignments: Record<string, string>): string {
48+
return Object.entries(assignments)
49+
.map(([name, variant]) => `${name}:${variant}`)
50+
.join("|");
51+
}

0 commit comments

Comments
 (0)