Реалізація кастомної платформи A/B-тестування на сайті
Готові інструменти (Optimizely, VWO, Google Optimize) коштують тисячі доларів на місяць, вводять сторонні JS-скрипти в critical path завантаження, дають обмежений доступ до сирих даних і не інтегруються з внутрішньою аналітикою. Кастомна платформа вирішує всі ці проблеми за ціною 2–3 тижнів розробки.
Архітектура
┌─────────────────────────────────────────────────────────────┐
│ Web App │
│ ┌──────────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ Assignment │ │ Tracking │ │ Admin UI │ │
│ │ Service │ │ (events) │ │ (results) │ │
│ └──────┬───────┘ └──────┬───────┘ └────────────────┘ │
└─────────┼───────────────────┼─────────────────────────────────┘
↓ ↓
┌──────────────┐ ┌──────────────┐
│ Experiments │ │ Event Store │
│ DB │ │ (ClickHouse) │
│ (PostgreSQL) │ │ │
└──────────────┘ └──────────────┘
БД: схема експериментів
CREATE TABLE experiments (
id SERIAL PRIMARY KEY,
slug VARCHAR(100) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(20) DEFAULT 'draft', -- draft, running, paused, completed
traffic SMALLINT DEFAULT 100, -- % трафіку, що бере участь у експерименті
start_at TIMESTAMPTZ,
end_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE experiment_variants (
id SERIAL PRIMARY KEY,
experiment_id INTEGER REFERENCES experiments(id),
slug VARCHAR(100) NOT NULL, -- 'control', 'treatment_a', 'treatment_b'
name VARCHAR(255),
weight SMALLINT DEFAULT 50, -- % трафіку всередину експерименту
config JSONB DEFAULT '{}', -- кастомні параметри варіанту
UNIQUE(experiment_id, slug)
);
CREATE TABLE user_assignments (
user_id BIGINT NOT NULL,
experiment_id INTEGER REFERENCES experiments(id),
variant_id INTEGER REFERENCES experiment_variants(id),
assigned_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (user_id, experiment_id)
);
-- Партиціювання за experiment_id для швидкої роботи з великими обсягами
CREATE INDEX ON user_assignments (experiment_id, variant_id);
Assignment Service—детерміноване розподілення
Ключова вимога: користувач завжди попадає в один варіант для одного експерименту. Рішення—hash-based assignment: hash(user_id + experiment_slug) % 100.
class ExperimentAssignmentService
{
private array $experimentsCache = [];
public function getVariant(int $userId, string $experimentSlug): ?string
{
$experiment = $this->getActiveExperiment($experimentSlug);
if (!$experiment) return null;
// Перевіряємо існуюче призначення
$existing = $this->assignmentRepo->find($userId, $experiment['id']);
if ($existing) {
return $existing['variant_slug'];
}
// Перевіряємо, чи попадає користувач у трафік експерименту
$trafficBucket = $this->hashToBucket($userId, $experimentSlug . '_traffic');
if ($trafficBucket >= $experiment['traffic']) {
return null; // користувач не бере участь в експерименті
}
// Вибираємо варіант
$variantBucket = $this->hashToBucket($userId, $experimentSlug);
$variant = $this->selectVariant($experiment['variants'], $variantBucket);
// Зберігаємо призначення
$this->assignmentRepo->assign($userId, $experiment['id'], $variant['id']);
// Відправляємо подію призначення
$this->eventTracker->track($userId, 'experiment.assigned', [
'experiment' => $experimentSlug,
'variant' => $variant['slug'],
]);
return $variant['slug'];
}
private function hashToBucket(int $userId, string $salt): int
{
// MurmurHash3 через PHP extension або реалізація
$hash = crc32($userId . '_' . $salt);
return abs($hash) % 100;
}
private function selectVariant(array $variants, int $bucket): array
{
// Варіанти з weights [50, 30, 20] → пороги [50, 80, 100]
$cumulative = 0;
foreach ($variants as $variant) {
$cumulative += $variant['weight'];
if ($bucket < $cumulative) {
return $variant;
}
}
return end($variants);
}
}
Трекінг подій
Відправляємо всі значущі дії користувача з контекстом експерименту:
class ExperimentEventTracker
{
public function track(int $userId, string $event, array $properties = []): void
{
// Додаємо контекст активних експериментів
$activeVariants = $this->assignmentRepo->getUserVariants($userId);
$payload = [
'event' => $event,
'user_id' => $userId,
'session_id' => session_id(),
'occurred_at' => now()->toIso8601String(),
'experiments' => $activeVariants, // ['checkout-button-color' => 'blue', ...]
'properties' => $properties,
];
// Ставимо в чергу для асинхронного записування в ClickHouse
$this->queue->push(new TrackExperimentEvent($payload));
}
}
ClickHouse таблиця для подій:
CREATE TABLE experiment_events (
event_date Date DEFAULT toDate(occurred_at),
occurred_at DateTime64(3, 'UTC'),
user_id UInt64,
session_id String,
event LowCardinality(String),
experiment LowCardinality(String),
variant LowCardinality(String),
properties String -- JSON
) ENGINE = MergeTree()
PARTITION BY (event_date, experiment)
ORDER BY (experiment, variant, user_id, occurred_at)
TTL event_date + INTERVAL 90 DAY;
Обчислення результатів—Z-тест
import numpy as np
from scipy import stats
from dataclasses import dataclass
@dataclass
class VariantStats:
name: str
users: int
conversions: int
@property
def conversion_rate(self) -> float:
return self.conversions / self.users if self.users > 0 else 0
def calculate_significance(control: VariantStats, treatment: VariantStats) -> dict:
"""Двосторонній z-тест для пропорцій"""
p1 = control.conversion_rate
p2 = treatment.conversion_rate
n1 = control.users
n2 = treatment.users
# Об'єднана пропорція
p_pool = (control.conversions + treatment.conversions) / (n1 + n2)
se = np.sqrt(p_pool * (1 - p_pool) * (1/n1 + 1/n2))
if se == 0:
return {"error": "Insufficient data"}
z_score = (p2 - p1) / se
p_value = 2 * (1 - stats.norm.cdf(abs(z_score)))
# Довірчий інтервал для різниці
diff = p2 - p1
se_diff = np.sqrt(p1*(1-p1)/n1 + p2*(1-p2)/n2)
ci_lower = diff - 1.96 * se_diff
ci_upper = diff + 1.96 * se_diff
return {
"control_rate": round(p1 * 100, 2),
"treatment_rate": round(p2 * 100, 2),
"relative_lift": round((p2 - p1) / p1 * 100, 2) if p1 > 0 else None,
"z_score": round(z_score, 4),
"p_value": round(p_value, 6),
"significant": p_value < 0.05,
"confidence_95": [round(ci_lower * 100, 2), round(ci_upper * 100, 2)],
"required_sample_size": calculate_required_sample(p1),
}
def calculate_required_sample(baseline_rate: float, mde: float = 0.05,
power: float = 0.8, alpha: float = 0.05) -> int:
"""Мінімальний розмір вибірки для виявлення ефекту mde при заданій потужності"""
z_alpha = stats.norm.ppf(1 - alpha/2)
z_beta = stats.norm.ppf(power)
p2 = baseline_rate * (1 + mde)
p_bar = (baseline_rate + p2) / 2
n = (z_alpha * np.sqrt(2 * p_bar * (1-p_bar)) + z_beta * np.sqrt(baseline_rate*(1-baseline_rate) + p2*(1-p2)))**2 / (p2 - baseline_rate)**2
return int(np.ceil(n))
Інтеграція Feature Flags
A/B-тестування й feature flags—суміжні концепції. Варіант експерименту може містити конфігурацію фіч:
// Варіант 'treatment_a' має config: {"checkout_steps": 1, "show_trust_badges": true}
$variant = $experimentService->getVariant($userId, 'checkout-redesign');
$config = $experimentService->getVariantConfig('checkout-redesign', $variant);
$checkoutSteps = $config['checkout_steps'] ?? 3; // дефолт для контрольної групи
$showTrustBadges = $config['show_trust_badges'] ?? false;
Захист від SRM (Sample Ratio Mismatch)
Якщо співвідношення користувачів у групах значно відрізняється від очікуваного—результати ненадійні:
-- Перевіряємо SRM для експерименту 'checkout-redesign'
SELECT
v.slug,
COUNT(*) as assigned_users,
v.weight as expected_weight,
COUNT(*) * 100.0 / SUM(COUNT(*)) OVER () as actual_pct
FROM user_assignments ua
JOIN experiment_variants v ON v.id = ua.variant_id
JOIN experiments e ON e.id = ua.experiment_id
WHERE e.slug = 'checkout-redesign'
GROUP BY v.slug, v.weight;
-- Тест хі-квадрат на рівномірність розподілу
-- Якщо p < 0.01—SRM, результати під питанням
Таймлайн
Дні 1–2—схема БД, Assignment Service з hash-based розподіленням, unit-тести детермінованості.
Дні 3–4—трекінг подій, воркер для запису в ClickHouse, інтеграція з існуючою аутентифікацією.
Дні 5–6—обчислення результатів (z-тест, довірчі інтервали), Admin UI для запуску й моніторингу експериментів.
День 7—SRM перевірки, документація для product-команди, пілотний запуск першого експерименту.







