Реалізація кастомної платформи A/B-тестування на сайті

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація кастомної платформи A/B-тестування на сайті
Складна
~1-2 тижні
Часті питання

Наші компетенції:

Етапи розробки

Останні роботи

  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Реалізація кастомної платформи 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-команди, пілотний запуск першого експерименту.