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

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.
Разработка и обслуживание любых видов сайтов:
Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Реализация кастомной платформы A/B-тестирования на сайте
Сложная
~1-2 недели
Часто задаваемые вопросы
Наши компетенции:
Этапы разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1214
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1161
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    852
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    823
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    815

Реализация кастомной платформы 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

    # Pooled proportion
    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-команды, пилотный запуск первого эксперимента.