في مقال التفعيل والاحتفاظ وجدنا في البيانات أن من يربط قناة التنبيه في أسبوعه الأول يعود أكثر بسبع مرات، ورأينا أن المجموعات التي ظهرت لها قائمة خطوات البداية Onboarding Checklist عادت أكثر من التي قبلها. ولكن الجدول لا يثبت السبب، فقد يكون الموسم أو مصدر الزوار هو الذي تغير، والطريقة الوحيدة لكي تعرف أن القائمة نفسها هي التي رفعت التفعيل أن تعرضها على نصف المستخدمين الجدد في نفس الفترة وتقارن بالنصف الآخر، أي تجربة A/B.
وفي هذا المقال سوف نمر بالتجربة كاملة كما يمر بها فريق نمو صغير، من كتابة الفرضية إلى القرار، على مختبر يعمل في Docker. أما شرح GrowthBook بالتفصيل والإحصاء خلف اختبارات A/B فلهما مقال مستقل في مرحلة التحليلات، وفي هذا المقال سوف نتناول:
- وثيقة التجربة: الفرضية، ومقياس الهدف، ومقياس الحماية Guardrail، وحجم العينة، وقاعدة القرار قبل البدء.
- بناء المختبر: تطبيق تجريبي، وGrowthBook للتقسيم والتحليل، وPostgreSQL كمستودع للأحداث.
- حساب حجم العينة Sample Size بالأرقام.
- تشغيل التجربة بزيارات محاكاة Simulated Traffic، وقراءة النتيجة المبكرة ثم النهائية.
- نجرب العطل: تحويلات تضيع لأن التطبيق سجل الحدث قبل التعرض Exposure.
- القرار عندما لا تكون النتيجة حاسمة، وهو ما حدث فعلاً في هذه التجربة.
قبل أي كود: وثيقة التجربة
أغلب التجارب الفاشلة تفشل قبل أن تبدأ، لأن الفريق لم يحدد ماذا يقيس ومتى يتوقف وماذا سيفعل بالنتيجة، فيبقى ينظر إلى اللوحة كل يوم حتى يرى رقماً يعجبه. لذلك اكتب الوثيقة التالية قبل أن تكتب أي سطر، وهذه وثيقة تجربتنا:
| البند | في تجربتنا | لماذا |
|---|---|---|
| الفرضية Hypothesis | إذا عرضنا على المستخدم الجديد قائمة من ثلاث خطوات تبدأ بربط قناة التنبيه، فإن نسبة من يربط قناة تنبيه خلال 72 ساعة سترتفع من 30% إلى 34.5% على الأقل | الفرضية تذكر التغيير، والمقياس، والرقم المتوقع، والسبب |
| مقياس الهدف Goal Metric | نسبة من سجل حدث alert_connected خلال 72 ساعة من أول تعرض | مقياس واحد يحسم القرار، وهو لحظة التفعيل التي وجدناها في المقال السابق |
| مقياس الحماية Guardrail Metric | نسبة من ألغى التجربة المجانية trial_cancelled | للتأكد أن القائمة لا تزعج المستخدمين فتدفعهم للمغادرة |
| أصغر فرق يهمنا Minimum Detectable Effect | 15% نسبياً، أي من 30% إلى 34.5% | الفرق الأصغر من ذلك لا يستحق أن نبني عليه، ويحتاج عينة أكبر بكثير |
| حجم العينة | 1647 مستخدماً في كل نسخة، أي 3294 على الأقل | نحسبه بعد قليل، ولا نقرر قبل الوصول إليه |
| قاعدة القرار | نعمم القائمة إذا كانت p-value أقل من 0.05 والفرق موجب، ولم يرتفع الإلغاء | تكتب قبل البدء، لكي لا تتغير بعد رؤية النتيجة |
المتطلبات
- Docker مع Docker Compose، وحوالي 4.7 GB لـ Docker Images، منها 2.67 GB لـ GrowthBook وحده و1.28 GB لـ MongoDB.
- المنافذ 18840 لواجهة GrowthBook، و18841 لواجهة البرمجة API، و18842 للتطبيق التجريبي، وكلها على
127.0.0.1فقط. - متصفح لواجهة GrowthBook، وNode.js إذا أردت أن تشغل حاسبة حجم العينة خارج الـ Container.
بناء المختبر
المختبر فيه أربع خدمات، والصورة التالية تبين دور كل منها وكيف تتحرك البيانات بينها:

ولاحظ أن GrowthBook لا يستقبل أحداث المستخدمين ولا يخزنها، وإنما يقرأها من قاعدة بياناتك بـ SQL عند كل تحليل، وهذا هو الفرق الأساسي بينه وبين أدوات مثل PostHog التي تجمع الأحداث عندها. وهذا يناسب الشركة التي عندها جدول أحداث أصلاً، ويعني أن بيانات المستخدمين لا تخرج من قاعدة بياناتك.
وشكل المجلد كما يلي:
growth-lab/
compose.yaml
warehouse.sql
.env
app/
Dockerfile
package.json
server.mjs
simulate.mjs
size.mjsنبدأ بملف .env وفيه كلمات المرور والمفاتيح، ونولدها عشوائياً بدل أن نكتبها بأيدينا:
{ echo "MONGO_PASSWORD=$(openssl rand -hex 16)"; echo "PG_PASSWORD=$(openssl rand -hex 16)"; \
echo "JWT_SECRET=$(openssl rand -hex 32)"; echo "ENCRYPTION_KEY=$(openssl rand -hex 32)"; } > .envثم ملف compose.yaml:
# Growth experiment lab: GrowthBook (assignment + analysis), PostgreSQL as the events warehouse, a demo app.
name: mki-gb
services:
mongo: # GrowthBook's own settings (features, experiments, users)
image: mongo:8.0.32
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD}
volumes: [mongodata:/data/db]
warehouse: # the app writes exposures and events here, GrowthBook reads them with SQL
image: postgres:16.15-alpine
environment:
POSTGRES_DB: product
POSTGRES_USER: product
POSTGRES_PASSWORD: ${PG_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
- ./warehouse.sql:/docker-entrypoint-initdb.d/warehouse.sql:ro
healthcheck:
test: ["CMD", "pg_isready", "-U", "product"]
interval: 5s
growthbook:
image: growthbook/growthbook:5.1.0
ports:
- "127.0.0.1:18840:3000" # web UI
- "127.0.0.1:18841:3100" # API and SDK endpoint
depends_on: [mongo, warehouse]
environment:
MONGODB_URI: mongodb://root:${MONGO_PASSWORD}@mongo:27017/growthbook?authSource=admin
APP_ORIGIN: http://localhost:18840
API_HOST: http://localhost:18841
JWT_SECRET: ${JWT_SECRET}
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
NODE_ENV: production
volumes: [uploads:/usr/local/src/app/packages/back-end/uploads]
app: # the demo product: signup page with or without the onboarding checklist
build: ./app
ports: ["127.0.0.1:18842:3000"]
depends_on:
warehouse: { condition: service_healthy }
environment:
DATABASE_URL: postgres://product:${PG_PASSWORD}@warehouse:5432/product
GB_API_HOST: http://growthbook:3100
GB_CLIENT_KEY: ${GB_CLIENT_KEY:-}
volumes:
mongodata:
pgdata:
uploads:في الإعداد أعلاه لاحظ التالي:
- ثبتنا الإصدارات كلها، ومنها
growthbook/growthbook:5.1.0، والسبب أن الوسمlatestفي GrowthBook يتبع الفرع الرئيسي كما يذكر دليل الاستضافة الذاتية. ENCRYPTION_KEYيشفر بيانات الاتصال بقاعدة البيانات التي يحفظها GrowthBook في MongoDB، فإذا ضاع هذا المفتاح فلن يستطيع GrowthBook أن يفتح اتصالاته المحفوظة، لذلك احفظه مع النسخة الاحتياطية.- الـ MongoDB هنا مناسبة للتجربة فقط، ويذكر نفس الدليل أن الإنتاج يحتاج MongoDB مدارة أو قاعدة متوافقة معها.
- كل المنافذ مربوطة على
127.0.0.1، وإذا أردت أن تفتح الواجهة لفريقك فضعها خلف Reverse Proxy مع TLS.
وملف warehouse.sql ينشئ الجدولين عند أول تشغيل لـ PostgreSQL:
-- Two tables are enough for an experiment: who saw which variation, and what users did.
CREATE TABLE experiment_viewed (
user_id TEXT NOT NULL,
experiment_id TEXT NOT NULL,
variation_id TEXT NOT NULL,
timestamp TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE events (
user_id TEXT NOT NULL,
event TEXT NOT NULL, -- signup, alert_connected, trial_cancelled
timestamp TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON experiment_viewed (experiment_id, user_id);
CREATE INDEX ON events (event, user_id);التطبيق التجريبي
التطبيق يمثل الشاشة الأولى بعد التسجيل في أداة مراقبة خوادم، فإذا كانت ميزة onboarding-checklist مفعلة للمستخدم رأى القائمة، وإلا رأى لوحة فارغة. والملف app/package.json يثبت نسخ المكتبتين:
{
"name": "mki-growth-app",
"private": true,
"type": "module",
"dependencies": {
"@growthbook/growthbook": "1.8.0",
"pg": "8.23.1"
}
}والملف app/Dockerfile:
FROM node:24.22.1-bookworm-slim
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev --no-audit --no-fund
COPY *.mjs ./
USER node
CMD ["node", "server.mjs"]والتطبيق نفسه app/server.mjs:
// Demo product: the first screen after signup, with or without the onboarding checklist.
// GrowthBook decides the variation (feature "onboarding-checklist"); the app records exposures and events in PostgreSQL.
import http from 'node:http';
import pg from 'pg';
import { GrowthBook } from '@growthbook/growthbook';
const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const { GB_API_HOST, GB_CLIENT_KEY } = process.env;
// The SDK payload (features and experiment rules) is fetched from GrowthBook and refreshed every 10 seconds
let payload = { features: {} };
async function refresh() {
if (!GB_CLIENT_KEY) return;
try { payload = await (await fetch(`${GB_API_HOST}/api/features/${GB_CLIENT_KEY}`)).json(); }
catch (e) { console.error('cannot load features:', e.message); } // keep the last payload; defaults apply if none
}
await refresh();
setInterval(refresh, 10_000);
function gbFor(userId) {
const gb = new GrowthBook({
attributes: { id: userId },
// called when the user is put into a variation of a running experiment
trackingCallback: (experiment, result) =>
db.query('INSERT INTO experiment_viewed (user_id, experiment_id, variation_id) VALUES ($1, $2, $3)',
[userId, experiment.key, String(result.key)]).catch(e => console.error(e.message)),
});
gb.initSync({ payload });
return gb;
}
const page = (uid, checklist) => `<!doctype html><html lang="ar" dir="rtl"><meta charset="utf-8"><title>مرحباً</title>
<body style="font-family:system-ui;max-width:640px;margin:40px auto;line-height:1.8">
<h1>أهلاً بك في مراقب الخوادم</h1><p>المستخدم: <code>${uid}</code></p>
${checklist ? `<h2>ابدأ في ثلاث خطوات</h2><ol>
<li>أضف أول خادم تريد مراقبته</li><li><b>اربط قناة التنبيه Alert Channel</b>: Slack أو البريد أو SMS</li><li>جرب تنبيهاً تجريبياً</li></ol>`
: `<p>لوحة التحكم فارغة، أضف خادماً من القائمة.</p>`}
<button>اربط قناة التنبيه</button></body></html>`;
http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://app');
const uid = url.searchParams.get('uid');
try {
if (url.pathname === '/start' && uid) { // first screen after signup
await db.query('INSERT INTO events (user_id, event) VALUES ($1, $2)', [uid, 'signup']);
const checklist = gbFor(uid).isOn('onboarding-checklist');
if (url.searchParams.get('format') === 'json') {
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ uid, checklist }));
} else res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(page(uid, checklist));
} else if (url.pathname === '/event' && uid && req.method === 'POST') {
await db.query('INSERT INTO events (user_id, event) VALUES ($1, $2)', [uid, url.searchParams.get('name')]);
res.writeHead(204).end();
} else res.writeHead(404).end();
} catch (e) { res.writeHead(500).end(e.message); }
}).listen(3000, () => console.log('app on :3000, client key', GB_CLIENT_KEY ? 'set' : 'missing (everyone sees the default)'));في الكود أعلاه لاحظ التالي:
- التطبيق يجلب إعدادات الـ Features من GrowthBook كل 10 ثوان ويحفظها في الذاكرة، ثم يقرر النسخة لكل مستخدم محلياً دون أي طلب شبكة، وإذا توقف GrowthBook يبقى التطبيق يعمل بآخر إعدادات وصلته، وهذا ما شرحناه في مقال Feature Flags.
- التقسيم يعتمد على هاش Hash لمعرف المستخدم
id، فنفس المستخدم يرى نفس النسخة في كل زيارة دون أن نحفظ شيئاً. - الدالة
trackingCallbackهي أهم سطر في التجربة، لأنها تسجل أن هذا المستخدم دخل التجربة ورأى النسخة كذا، وبدونها لا يعرف GrowthBook من رأى ماذا، وسوف نرى بعد قليل خطأ فيها يضيع بعض التحويلات.
بعد ذلك نشغل الخدمات:
docker compose up -d --buildوننتظر حتى يجيب GrowthBook على فحص الصحة:
curl -s http://127.0.0.1:18841/healthcheck{"status":200,"healthy":true}إعداد GrowthBook
افتح http://localhost:18840 وأنشئ الحساب الأول، وهو يصبح مدير الجهة تلقائياً، ثم نحتاج أربعة أشياء في الواجهة. والإعداد الأول هو مصدر البيانات Data Source من Metrics and Data ← Data Sources، نختار PostgreSQL ونكتب اسم الخدمة warehouse كعنوان لأن GrowthBook يتصل بها من داخل شبكة Docker، ثم نضيف استعلام التعرض Experiment Assignment Query الذي يقول لـ GrowthBook من رأى أي نسخة:
SELECT user_id, timestamp, experiment_id, variation_id FROM experiment_viewed
والإعداد الثاني هو المقياسان من Metrics، كلاهما من نوع Binomial، أي نسبة من قام بالحدث، وكل منهما باستعلام بسيط ونافذة تحويل Conversion Window مدتها 72 ساعة من أول تعرض:
-- مقياس الهدف: ربط قناة التنبيه Alert connected
SELECT user_id, timestamp FROM events WHERE event = 'alert_connected'
-- مقياس الحماية: إلغاء التجربة Trial cancelled (Inverse: الأقل أفضل)
SELECT user_id, timestamp FROM events WHERE event = 'trial_cancelled'والإعداد الثالث هو اتصال الـ SDK من SDK Configuration ← SDK Connections، نختار Node.js وبيئة production، فيعطينا مفتاح عميل يبدأ بـ sdk-. وهذا المفتاح ليس سراً، فهو يقرأ إعدادات الـ Features فقط، ولذلك نضعه في .env ونعيد تشغيل التطبيق:
echo "GB_CLIENT_KEY=sdk-xxxxxxxxxxxxxxxx" >> .env
docker compose up -d appوالإعداد الرابع هو التجربة نفسها: ننشئ ميزة Feature من نوع Boolean باسم onboarding-checklist وقيمتها الافتراضية false، ونضيف لها قاعدة تجربة Experiment Rule تقسم المستخدمين 50% و50% حسب id، ونكتب في التجربة الفرضية ومقياس الهدف ومقياس الحماية ونختار المحرك الإحصائي Frequentist لكي يتطابق مع طريقة حساب حجم العينة. ثم ننشر التعديل ونبدأ التجربة، وهذا ما فعلناه بواجهة البرمجة REST API لأنها تسمح بأتمتة نفس الخطوات في CI:

والآن نتأكد أن التطبيق يقسم المستخدمين فعلاً، فنفتح الصفحة لمستخدمين مختلفين، والصورة التالية تبين النسختين:

كم مستخدماً نحتاج؟
حجم العينة يعتمد على أربعة أرقام: النسبة الحالية 30%، وأصغر فرق يهمنا 15% نسبياً، ومستوى الدلالة 5%، وقوة الاختبار Statistical Power وهي 80%، أي احتمال أن نكتشف الفرق إذا كان موجوداً فعلاً. والملف app/size.mjs يحسبه بنفس معادلة حاسبة Evan Miller:
// Sample size per variation for a conversion metric (two-sided test, normal approximation),
// the same formula as Evan Miller's calculator.
// node size.mjs <baseline> <relative-lift> [alpha] [power]
const [p1, lift, alpha = 0.05, power = 0.8] = process.argv.slice(2).map(Number);
// standard normal quantile (Acklam's rational approximation, error below 1.2e-9)
function quantile(p) {
const a = [-39.69683028665376, 220.9460984245205, -275.9285104469687, 138.357751867269, -30.66479806614716, 2.506628277459239];
const b = [-54.47609879822406, 161.5858368580409, -155.6989798598866, 66.80131188771972, -13.28068155288572];
const c = [-0.007784894002430293, -0.3223964580411365, -2.400758277161838, -2.549732539343734, 4.374664141464968, 2.938163982698783];
const d = [0.007784695709041462, 0.3224671290700398, 2.445134137142996, 3.754408661907416];
if (p > 0.5) return -quantile(1 - p);
if (p < 0.02425) {
const r = Math.sqrt(-2 * Math.log(p));
return (((((c[0] * r + c[1]) * r + c[2]) * r + c[3]) * r + c[4]) * r + c[5]) / ((((d[0] * r + d[1]) * r + d[2]) * r + d[3]) * r + 1);
}
const r = p - 0.5, s = r * r;
return (((((a[0] * s + a[1]) * s + a[2]) * s + a[3]) * s + a[4]) * s + a[5]) * r / (((((b[0] * s + b[1]) * s + b[2]) * s + b[3]) * s + b[4]) * s + 1);
}
const zA = quantile(1 - alpha / 2), zB = quantile(power);
const p2 = p1 * (1 + lift), delta = p2 - p1;
const n = Math.ceil(((zA * Math.sqrt(2 * p1 * (1 - p1)) + zB * Math.sqrt(p1 * (1 - p1) + p2 * (1 - p2))) / delta) ** 2);
console.log(`baseline ${(100 * p1).toFixed(1)}% -> ${(100 * p2).toFixed(1)}% (relative lift ${(100 * lift).toFixed(0)}%), alpha ${alpha}, power ${power}`);
console.log(`z(1-alpha/2) = ${zA.toFixed(4)}, z(power) = ${zB.toFixed(4)}`);
console.log(`users per variation: ${n}, both variations: ${2 * n}`);ونشغله داخل Container التطبيق لثلاثة أحجام للفرق:
docker compose exec -T app node --version
docker compose exec -T app node size.mjs 0.30 0.15
docker compose exec -T app node size.mjs 0.30 0.10
docker compose exec -T app node size.mjs 0.30 0.063والمخرج سوف يكون كما يلي:
v24.22.1
baseline 30.0% -> 34.5% (relative lift 15%), alpha 0.05, power 0.8
z(1-alpha/2) = 1.9600, z(power) = 0.8416
users per variation: 1647, both variations: 3294
baseline 30.0% -> 33.0% (relative lift 10%), alpha 0.05, power 0.8
z(1-alpha/2) = 1.9600, z(power) = 0.8416
users per variation: 3692, both variations: 7384
baseline 30.0% -> 31.9% (relative lift 6%), alpha 0.05, power 0.8
z(1-alpha/2) = 1.9600, z(power) = 0.8416
users per variation: 9276, both variations: 18552وفي المخرج أعلاه لاحظ أن الفرق النسبي 15% يحتاج 1647 مستخدماً في كل نسخة، وإذا أردنا أن نكتشف فرقاً أصغر بالثلث، أي 10%، احتجنا 3692، وفرق 6% يحتاج أكثر من 9 آلاف في كل نسخة. والسطر الأخير سوف نحتاجه عند القرار، لأن 6.3% هو الفرق الذي ظهر في النتيجة النهائية. ولو كانت شركتك تستقبل 330 تسجيلاً في الأسبوع، كما في مقال التفعيل والاحتفاظ، فإن تجربة الـ 15% تحتاج عشرة أسابيع، وتجربة الـ 10% تحتاج أكثر من 22 أسبوعاً، وهذا هو السبب في أن الشركات الصغيرة يجب أن تختبر التغييرات الكبيرة لا التفاصيل الصغيرة.
تشغيل التجربة بزيارات محاكاة
الملف app/simulate.mjs يمثل المستخدمين الجدد، ويعطي كل مستخدم رقماً عشوائياً ثابتاً مشتقاً من معرفه، لكي يعطي نفس النتيجة في كل تشغيل:
// Simulated traffic: N new users open the first screen, then "decide" whether to connect an alert channel.
// The behaviour is ours, not real people's: the true rates below are assumptions, so we know the right answer
// in advance and can check whether the experiment finds it.
// node simulate.mjs <users> <first-user-number>
const APP = process.env.APP_URL || 'http://app:3000';
const TRUE_RATE = { control: 0.30, checklist: 0.345 }; // alert_connected: +15% relative, the effect we hope to detect
const CANCEL_RATE = 0.06; // trial_cancelled: same in both, the guardrail should stay flat
// deterministic random number per user and purpose (a string hash fed to mulberry32), so runs are repeatable
function rand(seedText) {
let h = 1779033703;
for (const c of seedText) { h = Math.imul(h ^ c.charCodeAt(0), 3432918353); h = (h << 13) | (h >>> 19); }
let t = (h += 0x6D2B79F5);
t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
const [n, from] = process.argv.slice(2).map(Number);
const seen = { control: 0, checklist: 0 }, conv = { control: 0, checklist: 0 };
for (let i = from; i < from + n; i++) {
const uid = `sim-${i}`;
const { checklist } = await (await fetch(`${APP}/start?uid=${uid}&format=json`)).json();
const arm = checklist ? 'checklist' : 'control';
seen[arm]++;
if (rand(uid + ':alert') < TRUE_RATE[arm]) {
conv[arm]++;
await fetch(`${APP}/event?uid=${uid}&name=alert_connected`, { method: 'POST' });
}
if (rand(uid + ':cancel') < CANCEL_RATE) await fetch(`${APP}/event?uid=${uid}&name=trial_cancelled`, { method: 'POST' });
}
for (const arm of ['control', 'checklist'])
console.log(`${arm.padEnd(10)} users ${String(seen[arm]).padStart(5)} alert_connected ${String(conv[arm]).padStart(5)} ${(100 * conv[arm] / seen[arm]).toFixed(1)}%`);النظرة المبكرة: 600 مستخدم
نبدأ بـ 600 مستخدم فقط، كما يحدث في الأيام الأولى لأي تجربة:
docker compose exec -T app node simulate.mjs 600 1control users 295 alert_connected 75 25.4%
checklist users 305 alert_connected 96 31.5%ثم نحدث النتائج في GrowthBook من زر Update في تبويب Results، فتظهر الصورة التالية:

الفرق يبدو كبيراً: 25.4% مقابل 31.1%، وقد يتحمس أحد في الفريق ويقول «القائمة رفعت التفعيل 22%، لنعممها اليوم». ولكن GrowthBook يكتب Not enough data بدل p-value، والسبب أنه يخفي النتيجة حتى يصل عدد التحويلات في النسخة الأساسية إلى حد أدنى، والقيمة الافتراضية لهذا الحد 150 كما في الكود المصدري للإصدار 5.1.0، ويذكر توثيق التجارب أن الهدف منه منع النظر المبكر Peeking. وقرأنا القيم من واجهة البرمجة لكي نرى ما تخفيه الواجهة:
users 600 SRM p-value 0.6831
## ربط قناة التنبيه Alert connected
Control users 295 conversions 75 rate 25.42%
Checklist users 305 conversions 95 rate 31.15% change +22.5% CI [-9.0%, +54.1%] p-value 0.162
## إلغاء التجربة Trial cancelled
Control users 295 conversions 12 rate 4.07%
Checklist users 305 conversions 16 rate 5.25% change +29.0% CI [-65.5%, +123.4%] p-value 0.547فترة الثقة Confidence Interval للفرق تمتد من 9% سالب إلى 54% موجب، أي أن البيانات حتى الآن تقبل أن تكون القائمة ضارة أو مفيدة جداً، والـ p-value هي 0.162، فلا يوجد ما نقرره. وهذا ما يشرحه Evan Miller في مقال How Not To Run an A/B Test: من ينظر إلى النتيجة كل يوم ويتوقف عند أول دلالة إحصائية يرفع نسبة النتائج الكاذبة كثيراً، والحل أن تحدد حجم العينة قبل البدء وتلتزم به.
النتيجة النهائية: 3400 مستخدم
نكمل حتى 3400 مستخدم، أي أكثر من 3294 التي حسبناها:
docker compose exec -T app node simulate.mjs 2800 601control users 1426 alert_connected 442 31.0%
checklist users 1374 alert_connected 442 32.2%ونحدث النتائج مرة أخرى:

ونفس الأرقام من واجهة البرمجة، عبر سكربت صغير results.py يطبع الحقول المهمة من رد /api/v1/experiments/<id>/results:
curl -s -H "Authorization: Bearer $GB_SECRET" http://localhost:18841/api/v1/experiments/<id>/results \
| docker run --rm -i -v "$(pwd)":/lab -w /lab python:3.13.17-slim python3 results.pyusers 3400 SRM p-value 0.4713
## ربط قناة التنبيه Alert connected
Control users 1721 conversions 515 rate 29.92%
Checklist users 1679 conversions 534 rate 31.80% change +6.3% CI [-4.4%, +17.0%] p-value 0.250
## إلغاء التجربة Trial cancelled
Control users 1721 conversions 94 rate 5.46%
Checklist users 1679 conversions 103 rate 6.13% change +12.3% CI [-18.2%, +42.8%] p-value 0.428وقبل أن نقرأ الفرق نتأكد أن التقسيم نفسه سليم، ففي تبويب Health يفحص GrowthBook عدم تطابق نسبة العينة Sample Ratio Mismatch، أي هل وصل إلى كل نسخة عدد قريب من المتوقع:

ولو كان الفرق بين النسختين كبيراً، مثل 2000 مقابل 1400 في تقسيم 50% و50%، لكان هذا دليلاً على خطأ في التطبيق، مثل أن نسخة تتعطل قبل أن تسجل التعرض، وعندها لا قيمة لأي نتيجة حتى تصلح الخطأ.
نجرب العطل: تحويلات ضاعت قبل أن يسجل التعرض
إذا قارنت أرقام GrowthBook بأرقام السكربت وجدت فرقاً صغيراً، فسألنا قاعدة البيانات مباشرة عن عدد من ربط قناة التنبيه في كل نسخة:
docker exec mki-gb-warehouse-1 psql -U product -c "SELECT v.variation_id, COUNT(DISTINCT v.user_id) AS users, COUNT(DISTINCT e.user_id) FILTER (WHERE e.event='alert_connected') AS alert_connected FROM experiment_viewed v LEFT JOIN events e ON e.user_id = v.user_id GROUP BY 1 ORDER BY 1" variation_id | users | alert_connected
--------------+-------+-----------------
0 | 1721 | 517
1 | 1679 | 538
(2 rows)قاعدة البيانات فيها 517 و538، وGrowthBook حسب 515 و534، أي أن 6 تحويلات لم تحسب. والسبب أن GrowthBook يعد الحدث فقط إذا وقع بعد أول تعرض للمستخدم، وهذا صحيح منطقياً، فنبحث عن الأحداث التي سبقت التعرض:
docker exec mki-gb-warehouse-1 psql -U product -c "SELECT v.variation_id, COUNT(*) AS alert_before_exposure, ROUND(AVG(EXTRACT(EPOCH FROM v.timestamp - e.timestamp)*1000)) AS avg_ms_late FROM experiment_viewed v JOIN events e ON e.user_id = v.user_id AND e.event = 'alert_connected' WHERE e.timestamp < v.timestamp GROUP BY 1 ORDER BY 1" variation_id | alert_before_exposure | avg_ms_late
--------------+-----------------------+-------------
0 | 2 | 1
1 | 4 | 0
(2 rows)وجدنا 2 و4 مستخدمين، وهو نفس الفرق تماماً، وسجل التعرض عندهم متأخراً بأقل من ملي ثانية. والسبب في الكود: الدالة trackingCallback تكتب التعرض في قاعدة البيانات دون await، فيرد التطبيق على المستخدم ويرسل المستخدم طلب الحدث التالي قبل أن تنتهي كتابة التعرض. وفي تجربتنا ضاع 6 من 1055 تحويلاً، ولكن في تطبيق حقيقي يرسل التعرض إلى خدمة تحليلات عبر الشبكة قد يتأخر التعرض ثوان، وقد يضيع كلياً إذا أغلق المستخدم الصفحة، وعندها تتحيز النتيجة دون أن تنتبه.
والحل أن تسجل التعرض قبل أن تعرض النسخة للمستخدم، أو أن تضيف هامشاً زمنياً في استعلام المقياس، والأول أفضل لأنه يصلح السبب لا النتيجة.
القرار: ماذا نفعل بنتيجة غير حاسمة؟
لنقرأ النتيجة النهائية بهدوء:
- النسخة Checklist أعلى بـ 6.3% نسبياً، ولكن فترة الثقة من 4.4% سالب إلى 17% موجب، وp-value تساوي 0.250، أي أن قاعدة القرار التي كتبناها قبل البدء لم تتحقق.
- نحن نعرف أن الأثر الحقيقي 15% لأننا كتبناه في السكربت، والتجربة لم تكتشفه. وهذا ليس خطأ في GrowthBook، وإنما معنى قوة 80%: من كل خمس تجارب لها أثر حقيقي بهذا الحجم، تفوت واحدة تقريباً بسبب العشوائية وحدها، ونتيجتنا كانت من هذه الحالات.
- مقياس الحماية لم يصل إلى الحد الأدنى من البيانات، فلا نستطيع أن نقول إن القائمة لا ترفع الإلغاء.
وقد يتساءل البعض: إذا كان الفرق موجباً فلماذا لا نعمم القائمة؟ والإجابة أن هذا ممكن كقرار منتج، فالقائمة رخيصة ولا يوجد دليل على ضرر، ولكن يجب أن يكتب على أنه قرار منتج لا نتيجة تجربة، لأن البيانات لا تفرق بين أثر 0% وأثر 15%. وأما الخيار الذي يغري الفرق كثيراً، وهو أن نكمل نفس التجربة أسبوعاً آخر حتى تصبح دالة، فهو نفس مشكلة النظر المبكر، لأنك غيرت حجم العينة بعد أن رأيت النتيجة. والجدول التالي يجمع الخيارات:
| الخيار | متى يناسب | المشكلة |
|---|---|---|
| إبقاء النسخة الحالية Control | التغيير مكلف في الصيانة، أو له مخاطر | قد تخسر أثراً حقيقياً لم تكتشفه التجربة |
| التعميم كقرار منتج | التغيير رخيص، والنقطة الوسطى موجبة، ولا دليل على ضرر | لا تستطيع أن تنسب أي تحسن لاحق إلى القائمة |
| إطالة نفس التجربة | لا يناسب | نظر مبكر بشكل آخر، يرفع النتائج الكاذبة |
| تجربة جديدة بعينة جديدة | الفرق مهم ويستحق الانتظار | فرق 10% يحتاج 3692 في كل نسخة، أي أكثر من خمسة أشهر بمعدل 330 تسجيلاً في الأسبوع |
وقرارنا في هذه التجربة: النتيجة غير حاسمة، فنبقي النسخة الحالية، ونعيد تصميم القائمة لكي يكون أثرها أكبر، مثل أن تربط قناة التنبيه في نفس الشاشة بدل أن تشرح الخطوة، ثم نختبرها في تجربة جديدة. ونسجل القرار وسببه في GrowthBook نفسه عند إيقاف التجربة، لكي يجده من يأتي بعدنا:

ولاحظ في الصورة أعلاه أن GrowthBook يحفظ النص العربي ويعرضه، ولكنه يعرضه من اليسار إلى اليمين، فتنتقل علامة % والنقطة إلى أماكن خاطئة، ونفس الشيء يحدث في حقل الفرضية. وهذا لا يغير أي حساب، ولكن إذا كان فريقك يكتب بالعربية فاكتب النتائج في الوثيقة الأصلية للتجربة وضع رابطها في GrowthBook، أو اكتب في GrowthBook بالإنجليزية.
حذف المختبر
docker compose down -v
docker rmi growthbook/growthbook:5.1.0 mki-gb-appوالخيار -v يحذف الـ Volumes، أي إعدادات GrowthBook والأحداث، فلا تستخدمه على مختبر تريد أن تعود إليه.
مشكلات شائعة وحلولها
- كل المستخدمين يرون النسخة الافتراضية: التطبيق لم يجد مفتاح الـ SDK، أو التجربة ما زالت مسودة Draft، فتأكد من
GB_CLIENT_KEYفي سجل التطبيق، ومن أن حالة التجربة Running وأن التعديل على الميزة منشور Published. - GrowthBook لا يتصل بقاعدة البيانات: اكتب اسم الخدمة
warehouseلاlocalhost، لأنlocalhostداخل Container GrowthBook هو الـ Container نفسه. - Not enough data لفترة طويلة: الحد الأدنى للتحويلات يحسب في النسخة الأساسية، فالمقياس النادر مثل الإلغاء يحتاج عينة أكبر بكثير من مقياس الهدف، وخطط لذلك في حجم العينة.
- تحذير SRM: لا تقرأ النتيجة، وابحث عن نسخة تتعطل أو لا تسجل التعرض، وراجع دليل حل مشكلات التجارب.
- Multiple Exposures: المعرف الذي يقسم به الـ SDK يختلف عن المعرف المسجل في جدول التعرض، فاستخدم نفس المعرف في المكانين.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- اكتب الفرضية والمقياس ومقياس الحماية وحجم العينة وقاعدة القرار قبل أي كود، فهي التي تحميك من قراءة النتيجة على هواك.
- فرق 15% على نسبة 30% يحتاج 1647 مستخدماً في كل نسخة، والفرق الأصغر يحتاج أضعاف ذلك، لذلك تختبر الشركة الصغيرة التغييرات الكبيرة.
- بعد 600 مستخدم بدا الفرق 22.5%، وبعد 3400 أصبح 6.3% وغير دال، وهذا هو سبب منع النظر المبكر.
- التجربة فاتها أثر حقيقي نعرفه، وهذا متوقع مرة من كل خمس مرات بقوة 80%، فالنتيجة غير الدالة لا تعني أن التغيير بلا أثر.
- سجل التعرض قبل أن يرى المستخدم النسخة، وافحص SRM قبل أن تقرأ أي فرق.
ولمزيد من التفصيل في GrowthBook والإحصاء خلفه، وفي جمع الأحداث مع PostHog:
دليلاختبارات A/B بدقة مع GrowthBook على سيرفركنشغل GrowthBook مع PostgreSQL ونجري تجربة نعرف فرقها الحقيقي مسبقاً، لنتعلم قراءة فرصة الفوز والهامش وقيمة p دون أن نخدع أنفسنا.
دليلالتفعيل Activation والاحتفاظ Retention: لماذا يغادر المستخدمون؟كيف تجد اللحظة التي يرى فيها المستخدم قيمة منتجك، وتقرأ منحنى الاحتفاظ والتسرب، مع تجربة تحسبها بـ Python وSQL.
دليلأدوات النمو: Amplitude وMixpanel وبدائلها مفتوحة المصدرمقارنة أدوات تحليلات المنتج والتجارب والإحالة بالترخيص وآخر إصدار والسعر، مع اختبار فعلي للعربية في GrowthBook وUnleash.
دليلتسويق النمو Growth Marketing: مقاييس AARRR وحلقات النموكيف يعمل فريق النمو على كل مرحلة من مسار المستخدم، ولماذا تنمو الشركات بالحلقات لا بالقمع، وكم تجربة تحتاج.
دليلFeature Flags: انشر الكود وفعل الميزة لاحقاًكيف يفصل ال Feature Flag بين نشر الكود وإطلاق الميزة، وكيف تطلقها لجزء من المستخدمين أولاً وتوقفها فوراً إذا تعطلت.سجل التحديثات
- أكتوبر 2026: كتابة الدليل واختباره على GrowthBook 5.1.0 مع MongoDB 8.0.32 وPostgreSQL 16.15 وتطبيق Node.js 24.22.1 بالمكتبة @growthbook/growthbook 1.8.0، بزيارات محاكاة لـ 3400 مستخدم.