التسويق والمبيعات الأدلة التقنية

تحليلات المنتج Product Analytics مع PostHog على سيرفرك

نشغل PostHog على سيرفرنا ونتتبع المستخدم من أول زيارة إلى أول فاتورة، ونقيس ما يحتاجه من ذاكرة ووقت إقلاع.

تحليلات المنتج Product Analytics مع PostHog على سيرفرك

في مقارنة أدوات تحليلات الموقع عرفنا كم زائراً دخل الموقع ومن أين جاء، وفي قياس التسويق ربطنا الزيارة بالحملة التي جلبتها، ولكن السؤال الذي يهم صاحب المنتج يبدأ بعد ذلك: من هذا الزائر سجل فعلاً؟ ومن سجل هل أنشأ أول فاتورة؟ ومن أنشأ الفاتورة هل عاد في الأسبوع التالي؟ وهذه أسئلة تحليلات المنتج Product Analytics، وأشهر أداة مفتوحة المصدر لها هي PostHog.

المواضيع في مرحلة التحليلات:

وفي هذا الدليل سوف نقوم بما يلي:

  • نقرأ ما تقوله PostHog نفسها عن الاستضافة الذاتية Self-hosting اليوم: الدعم، والإصدارات، والحدود.
  • نشغل نسخة Hobby الرسمية في Docker مثبتة على Commit محدد، ونقيس الذاكرة والمعالج والقرص ووقت الإقلاع.
  • نربط تطبيق ويب صغيراً بالعربية، ونرسل منه الزيارات Pageviews والأحداث المخصصة Custom Events، ونربط الزائر بحسابه بالدالة identify.
  • نبني قمع التحويل Funnel وتقرير الاحتفاظ Retention، ونشاهد تسجيل جلسة Session Replay، ونفعل ميزة لنصف المستخدمين بـ Feature Flag.
  • نختبر أسماء أحداث وخصائص بالعربية، ونرى كيف تظهر في الواجهة.

والصورة التالية تبين طريق الحدث من المتصفح إلى التقارير:

المتصفح فيه التطبيق مع posthog-js يرسل إلى Caddy، وCaddy يوجه /e/ و/i/ إلى خدمة capture، و/s/ إلى replay-capture، و/flags إلى feature-flags، وcapture وreplay-capture يكتبان في Kafka (Redpanda)، وخدمات ingestion المكتوبة بـ Node.js تقرأ من Kafka وتكتب الأحداث في ClickHouse والأشخاص في PostgreSQL والتسجيلات في SeaweedFS، وتطبيق web المكتوب بـ Django يقرأ منها ليعرض التقارير، وفي الأسفل قائمة بخدمات أخرى في نفس الملف، والمجموع 39 خدمة
الحدث يمر بالـ Reverse Proxy ثم خدمة الاستقبال ثم الطابور ثم قواعد البيانات، وكل ذلك على سيرفر واحد

المتطلبات

  • سيرفر Ubuntu، وصفحة الاستضافة الذاتية تطلب ما يعادل 4 vCPU و16 GB من الذاكرة RAM وأكثر من 30 GB من التخزين، وسوف ترى في قسم الموارد لماذا لا تنزل عن ذلك.
  • نطاق فرعي Subdomain مثل analytics.example.com عليه سجل A يشير إلى السيرفر، والمنفذان 80 و443 مفتوحان، لأن الـ Caddy المدمج يطلب شهادة Let's Encrypt بنفسه.
  • حوالي 30 GB للـ Images وحدها، فقد بلغ مجموع الـ Images في تجربتنا 28.1 GB بعد فك الضغط.
  • تطبيق ويب تريد قياسه، وقد استخدمنا صفحات HTML بسيطة لمتجر فواتير تجريبي اسمه «فاتورتي».

ماذا تقول PostHog عن الاستضافة الذاتية Self-hosting؟

قبل أن تشغل أي أمر، اقرأ ما تقوله الشركة نفسها، لأنه يحدد هل هذا الخيار يناسبك أصلاً. في صفحة الاستضافة الذاتية وصفحة إخلاء المسؤولية كما قرأناهما في 25 أكتوبر 2026:

  • الدعم: الاستضافة الذاتية غير مدعومة رسمياً Officially Unsupported، ولا توجد خطط دعم مدفوعة لها، والشركة لا تقدم أي ضمانات، وتقول إنها لا تستطيع المساعدة في استعادة البيانات إذا فقدت.
  • الإصدارات: لا توجد إصدارات مرقمة Tagged Releases للاستضافة الذاتية، وكل تغيير ومنه الإصلاحات الأمنية يخرج باستمرار من فرع master إلى السحابة وإلى آخر Docker Image، ولذلك لا تنشر الشركة CVE، وتوصي بأن تعمل دائماً على آخر Image.
  • الحجم: كل شيء يعمل على سيرفر واحد، وتقول صفحة إخلاء المسؤولية إنه لن يتجاوز غالباً بضع مئات الآلاف من الأحداث دون جهد كبير في التوسع.
  • الميزات: كل ميزات الخطط المدفوعة متاحة في السحابة فقط، ومنها تحليلات المجموعات Group Analytics ومسارات المستخدم المتقدمة وإدارة الصلاحيات RBAC وSAML.
  • الترخيص: نسخة Docker Compose مجانية بترخيص MIT، وملف الترخيص يستثني مجلد ee/ الذي له ترخيص خاص.

وقد يتساءل البعض: رأينا في صفحة الإصدارات على GitHub إصدارات باسم hobby-20261025-002803، أليست هذه إصدارات للاستضافة الذاتية؟ والإجابة لا، فهذه الإصدارات فيها ملف واحد هو hobby-installer، وهو أداة تثبيت تفاعلية TUI ما زالت تجريبية Beta بحسب صفحة الاستضافة الذاتية، ووصف الإصدار يقول فقط إنها بنيت من Commit معين، وتاريخ الوسم هو تاريخ بناء أداة التثبيت وليس رقم إصدار لـ PostHog. لذلك فالذي تستطيع تثبيته فعلاً هو الـ Commit نفسه، وهذا ما سوف نفعله.

والجدول التالي يقارن الخيارين من حيث ما يهمك قبل القرار، والأرقام من صفحة الأسعار في 25 أكتوبر 2026:

البندPostHog السحابي CloudPostHog على سيرفرك Hobby
الأحداث مجاناً كل شهرأول مليون حدث، ثم 0.00005 دولار للحدث حتى مليونينبلا حد من PostHog، والحد هو سيرفرك
تسجيل الجلساتأول 5 آلاف تسجيل، ثم 0.005 دولار للتسجيلبلا حد من PostHog
طلبات الـ Feature Flagsأول مليون طلب، ثم 0.0001 دولار للطلببلا حد من PostHog
مكان البياناتمنطقة أمريكية أو أوروبيةسيرفرك
الدعم والتحديثتديره الشركةعليك وحدك، وبلا دعم رسمي
الميزات المدفوعةمتاحة بالدفعغير متاحة

لذلك إذا لم يكن عندك شرط يمنع خروج بيانات المستخدمين من سيرفرك، فالسحابة بخطتها المجانية أنسب لأغلب الشركات الناشئة كما شرحنا في مقارنة أدوات النمو، أما إذا كان عندك هذا الشرط وعندك من يدير السيرفر فتابع معنا.

التثبيت Installation بنسخة Hobby

الطريقة الرسمية هي سكربت deploy-hobby، وهو يسأل عن الوسم Tag والنطاق، والوسم الافتراضي هو latest. ولكن قبل أن تضغط Enter لاحظ أن latest يعني أن كل تثبيت جديد أو إعادة تشغيل بعد pull قد يأتي بكود مختلف، وهذا يتعارض مع قاعدة نلتزم بها في كل أدلة الموقع، وهي أن تعرف بالضبط ما الذي يعمل على سيرفرك. ولحسن الحظ أن السكربت يقبل رقم Commit كاملاً من 40 حرفاً بدلاً من الوسم، فيثبت الكود عليه ويستخدم Image بنفس الرقم، لذلك نمرر الـ Commit الذي اختبرناه ومعه النطاق:

mkdir -p /opt/posthog && cd /opt/posthog
curl -fsSL https://raw.githubusercontent.com/posthog/posthog/3dcce7bc74903c4593e2382b18937e3bafad00dd/bin/deploy-hobby -o deploy-hobby
bash deploy-hobby 3dcce7bc74903c4593e2382b18937e3bafad00dd analytics.example.com

في الأوامر أعلاه لاحظ التالي:

  • نزلنا السكربت من نفس الـ Commit وليس من HEAD، حتى لا يتغير السكربت نفسه بين يوم وآخر، ثم قرأناه قبل تشغيله، وهذا ما ننصحك به مع أي سكربت يطلب sudo.
  • هذا الـ Commit هو الذي بنيت منه أداة التثبيت hobby-20261025-002803، وتاريخه 25 أكتوبر 2026، والـ Image الخاصة به posthog/posthog:3dcce7bc74903c4593e2382b18937e3bafad00dd موجودة على Docker Hub.
  • السكربت يعمل على Ubuntu فقط، ويسأل في البداية عن مفاتيح Anthropic وOpenAI لميزات الذكاء الاصطناعي، واتركها فارغة إذا لم تحتجها.

وقد يتساءل البعض: ماذا يفعل هذا السكربت بالضبط؟ بعد أن قرأناه وجدنا أنه يقوم بما يلي:

  1. يثبت git وDocker وDocker Compose إذا لم تكن موجودة، ثم ينسخ مستودع PostHog ويثبته على الـ Commit.
  2. يكتب ملف .env فيه أسرار عشوائية مثل POSTHOG_SECRET وENCRYPTION_SALT_KEYS، مع النطاق والوسم، ولا يكتب فوقه إذا كان موجوداً، فاحفظ هذا الملف مع النسخ الاحتياطي لأن ضياعه يفسد البيانات المشفرة.
  3. ينزل قاعدة مواقع GeoLite2 إلى المجلد share/ لتحويل عنوان IP إلى مدينة ودولة.
  4. ينسخ docker-compose.hobby.yml إلى docker-compose.yml، ثم يشغل docker-compose up -d --pull always، وينتظر حتى يرجع /_health الرمز 200 لمدة أقصاها 10 دقائق.
  5. يرسل حدثين إلى PostHog Cloud عند بدء التثبيت وانتهائه، فيهما النطاق الذي أدخلته، فإذا كان اسم النطاق نفسه سراً فاحذف هذين السطرين من السكربت قبل تشغيله.

والملف الذي يشغله السكربت فيه 39 خدمة، وعندما فحصنا الـ Images فيه وجدنا أن تثبيت الـ Commit لا يثبت كل شيء:

الخدماتالـ Imageهل الإصدار ثابت؟
web وworker وtemporal-django-workerposthog/posthog:3dcce7bc…نعم، بالـ Commit (9.2 GB)
plugins وخدمات ingestion وrecording-apiposthog/posthog-node:latestلا، وسم latest (2.7 GB)
capture وfeature-flags وcymbal وlivestream وغيرهاghcr.io/posthog/posthog/*:masterلا، وسم master
Caddycaddyلا، بدون وسم أصلاً
ClickHouse وPostgreSQL وRedpanda وTemporal وغيرهامثل postgres:15.12-alpineنعم

لذلك بعد أول تشغيل ناجح سجل الـ Digest لكل Image، حتى تعرف ما الذي كان يعمل إذا تغير شيء بعد pull جديد:

cd /opt/posthog
for i in $(docker compose config --images | sort -u); do
  docker image inspect "$i" --format '{{index .RepoDigests 0}}'
done > images-$(date +%F).txt

كم يستغرق الإقلاع الأول Startup؟

السكربت ينتظر 10 دقائق، والتوثيق يقول من 5 إلى 10 دقائق، ولكن في تجربتنا على 8 أنوية و31 GB من الذاكرة أخذ الإقلاع الأول 77 دقيقة حتى رجع /_health الرمز 200. والسبب أن الـ Container المسمى web يطبق ترحيلات قاعدة البيانات Migrations قبل أن يبدأ الخادم، وطبق في تجربتنا 1215 ترحيلاً، وعملية manage.py migrate تعمل على نواة واحدة بنسبة 94% طوال هذا الوقت، فزيادة عدد الأنوية لا تسرعها. ويمكنك متابعة التقدم بالأمر التالي:

docker compose logs -f web | grep Applying
web-1  |   Applying signals.0117_drop_signalscoutconfig_team_id_fk_idx... OK
web-1  |   Applying signals.0119_squash_2026_09_07_schema_addons... OK

لذلك إذا انتهت مهلة السكربت برسالة Health check timed out after 10 minutes فلا تحذف شيئاً ولا تعد التثبيت، وإنما تابع السجلات Logs حتى تنتهي الترحيلات، وخلال ذلك سوف يرجع الـ Reverse Proxy الرمز 502 لأن web لم يبدأ بعد. وإعادة التشغيل لاحقاً أسرع ولكنها ليست فورية، فبعد docker compose stop ثم start أخذ رجوع /_health إلى 200 حوالي 10 دقائق، منها 8 دقائق في التحقق من ترحيلات Django ثم ترحيلات جداول الأشخاص ثم ترحيلات ClickHouse، مع أن المخرج كان No migrations to apply وClickhouse migrations up to date!، والسبب أن سكربت التشغيل يمر على كل الترحيلات في كل إقلاع.

وبعد الإقلاع افتح النطاق في المتصفح وأنشئ الحساب الأول والمنظمة، ثم من Settings انسخ Project token الذي يبدأ بـ phc_، وهو مفتاح كتابة عام يوضع في كود الصفحة، أي أنه ليس سراً، ولكنه لا يسمح بقراءة البيانات.

الموارد: الذاكرة RAM والمعالج CPU والقرص Disk

بعد الإقلاع وإرسال أحداث التجربة قسنا الموارد بالأمر docker stats، فكان مجموع الذاكرة 18.5 GB لـ 37 Container تعمل، والمعالج حوالي 72% من نواة واحدة والنظام شبه خامل. والجدول التالي فيه أكبر المستهلكين:

الخدمةالذاكرةدورها
worker7.1 GBمهام Celery في الخلفية
web5.1 GBالواجهة والـ API، بأربع عمليات Granian
temporal-django-worker1.4 GBالمهام الطويلة عبر Temporal
ClickHouse621 MBتخزين الأحداث والاستعلام عليها
Kafka (Redpanda)621 MBطابور الأحداث Queue
Elasticsearch557 MBيعمل رغم أن ملف hobby يوقف استخدام Temporal له بـ ENABLE_ES=false
zookeeper415 MBيحتاجه ClickHouse
خدمات Node.js الست (plugins وingestion)بين 177 و278 MB لكل منهامعالجة الأحداث والتسجيلات

في الجدول أعلاه لاحظ أن worker وحده أخذ 7.1 GB، والسبب أن Celery يشغل افتراضياً عدداً من العمليات Processes يساوي عدد الأنوية، وكل عملية تحمل Django كاملاً بحوالي 1.2 إلى 1.5 GB، وفي تجربتنا كانت الأنوية 8. لذلك على سيرفر من 4 vCPU يقل هذا الرقم، ويمكنك تحديد العدد بالمتغير WEB_CONCURRENCY الذي يقرأه سكربت bin/docker-worker-celery، ولكن لا تتوقع أن تعمل الحزمة كلها بأقل من 8 GB، وهذا ما يقوله السكربت نفسه في أول سطر يطبعه: «You REALLY need 8GB or more of memory».

أما القرص فبعد التجربة كان حجم الـ Volumes حوالي 2.4 GB، منها 1.9 GB لبيانات Redpanda التي تحجز مساحة مقاطعها مسبقاً، و172 MB لـ ClickHouse، و158 MB لـ PostgreSQL، وهذا قبل أي بيانات حقيقية، فأضفها إلى 28 GB للـ Images.

إرسال الأحداث Events من تطبيقك

الآن سوف نقوم بربط التطبيق، وأبسط طريقة هي مقطع الكود Snippet الرسمي من توثيق مكتبة JavaScript، وهو يحمل المكتبة من سيرفرك نفسه من المسار /static/array.js، فلا يتصل المتصفح بأي نطاق خارجي. وضعنا المقطع في ملف ph.js يحمل في كل صفحة، والجزء الذي يهمنا منه هو الإعداد:

posthog.init('phc_xxxxxxxx', {
    api_host: 'https://analytics.example.com',
    defaults: '2026-08-30',
    person_profiles: 'identified_only',
    session_recording: { maskAllInputs: true },
});

في الإعداد أعلاه لاحظ التالي:

  • api_host هو نطاق سيرفرك، والمكتبة ترسل إليه الأحداث على /e/ و/i/v0/e/، والتسجيلات على /s/، وطلبات الـ Flags على /flags، والـ Caddy المدمج يوجه كل مسار إلى خدمته كما في الصورة الأولى.
  • defaults تاريخ يختار مجموعة الإعدادات الافتراضية، وثبته حتى لا يتغير سلوك المكتبة مع تحديثها.
  • identified_only يعني أن PostHog لا ينشئ ملف شخص Person Profile للزائر المجهول إلا بعد identify، فتقل البيانات الشخصية المخزنة ويقل الحمل.
  • maskAllInputs يخفي كل ما يكتب في حقول الإدخال من التسجيلات، وهو الافتراضي أصلاً كما في صفحة خصوصية التسجيلات، وكتبناه صراحة حتى لا يغيره أحد دون أن ينتبه.

والزيارات Pageviews والنقرات تلتقط تلقائياً، أما الأحداث التي تهمك في المنتج فترسلها بنفسك، مثل حدث إنشاء الفاتورة في صفحة app.html، وأضفنا معه حدثاً باسم عربي وخصائص عربية لنختبر العربية:

posthog.capture('invoice_created', { amount_sar: amount, currency: 'SAR' });
posthog.capture('إرسال فاتورة', { 'طريقة الإرسال': 'واتساب', 'المدينة': 'الرياض' });

والصفحة التي اختبرنا عليها تبدو هكذا:

صفحة لوحة التحكم في تطبيق فاتورتي باتجاه من اليمين إلى اليسار، وفيها نموذج فاتورة جديدة بحقل اسم العميل وفيه مؤسسة النخيل وحقل المبلغ وفيه 1150 وزر إنشاء الفاتورة، وتحته رسالة تم إنشاء الفاتورة وإرسالها
صفحة إنشاء الفاتورة في التطبيق التجريبي، وكل ضغطة على الزر ترسل حدثين

هل وصلت الأحداث إلى ClickHouse؟

افتح Activity في الواجهة، أو استعلم مباشرة بلغة HogQL، وهي SQL على جداول PostHog، من صفحة SQL في الواجهة أو من الـ API. وبعد أن مررنا بالتطبيق من الصفحة الرئيسية إلى إنشاء الفاتورة شغلنا الاستعلام التالي:

select event, distinct_id, person.properties.email,
       properties.`طريقة الإرسال`, properties.`المدينة`
from events order by timestamp

والمخرج سوف يكون كما يلي، واختصرنا منه أحداث النقرات التلقائية:

$pageview        01a173db-fc98-7851-af06-45fdbc36401f  [email protected]
cta_clicked      01a173db-fc98-7851-af06-45fdbc36401f  [email protected]
$pageview        01a173db-fc98-7851-af06-45fdbc36401f  [email protected]
signed_up        user-sara                             [email protected]
$identify        user-sara                             [email protected]
$pageview        user-sara                             [email protected]
invoice_created  user-sara                             [email protected]
إرسال فاتورة     user-sara                             [email protected]  واتساب  الرياض

في المخرج أعلاه لاحظ التالي:

  • الأحداث الثلاثة الأولى أرسلت قبل التسجيل بمعرف عشوائي Anonymous ID، ومع ذلك يظهر بجانبها بريد سارة، لأن identify ربط المعرفين بنفس الشخص كما سنشرح في القسم التالي.
  • الحدث العربي وصل باسمه، والخصائص العربية تقرأ في HogQL بين علامتي `.

وفي صفحة Activity تظهر الأسماء العربية كما هي، حتى في وصف النقرات التلقائية الذي يأخذ نص الزر:

صفحة Activity في PostHog بواجهة إنجليزية، وفيها جدول أحداث منها اشتراك مدفوع وإرسال فاتورة بالعربية، وclicked button with text إنشاء الفاتورة، وinvoice_created وsigned_up وPageview، وعمود الشخص فيه بريد المستخدم، وعمود الرابط فيه صفحات التطبيق التجريبي
الواجهة بالإنجليزية ومن اليسار إلى اليمين، والأسماء العربية تظهر في اتجاهها الصحيح داخل الجدول

أحداث من السيرفر Server-side

بعض الأحداث لا يصح أن تأتي من المتصفح، مثل نجاح الدفع، لأن المتصفح قد يغلق قبل الإرسال، ولأن من يعرف الـ Token يستطيع أن يرسل ما يشاء. لذلك أرسلها من الكود الخاص بك بعد أن تتأكد من العملية، بنفس distinct_id الذي استخدمته في identify. والمثال التالي يرسل حدث اشتراك مدفوع بـ curl، والجسم في ملف لأن الاسم عربي:

cat > event.json <<'EOF'
{"api_key": "phc_xxxxxxxx", "event": "اشتراك مدفوع", "distinct_id": "user-sara",
 "properties": {"الخطة": "الاحترافية", "amount_sar": 49}}
EOF
curl -s -X POST https://analytics.example.com/i/v0/e/ \
  -H 'Content-Type: application/json' --data-binary @event.json
{"status":"Ok"}

ولاحظ أن الرد Ok يعني أن خدمة الاستقبال وضعت الحدث في الطابور، وليس أنه وصل إلى ClickHouse، فإذا كان الـ Token خاطئاً أو تعطلت المعالجة فلن يخبرك الرد بذلك، والتحقق الحقيقي هو أن تراه في Activity أو في استعلام HogQL.

من زائر مجهول إلى مستخدم معروف Identify

عندما يدخل الزائر لأول مرة تعطيه المكتبة معرفاً عشوائياً، وتحفظه في المتصفح، وكل أحداثه قبل التسجيل تحمل هذا المعرف. وعند التسجيل نستدعي identify بمعرف المستخدم من قاعدة بياناتك، فيربط PostHog المعرفين بشخص واحد، وتصبح أحداثه قبل التسجيل وبعده تاريخاً واحداً كما يقول توثيق identify. وهذا الكود في صفحة التسجيل:

var userId = 'user-' + email.split('@')[0];           // your own user id from the database
posthog.identify(userId, { email: email, store_name: store, plan: 'free' });
posthog.capture('signed_up', { method: 'email' });
خط زمني من اليمين إلى اليسار: في البداية زائر مجهول بمعرف عشوائي وأحداثه pageview وcta_clicked، ثم دائرة identify عند التسجيل مع الكود posthog.identify، ثم مستخدم معروف بالمعرف user-sara وأحداثه signed_up وinvoice_created وإرسال فاتورة، والجهتان تتجهان إلى مربع شخص واحد Person في PostHog فيه email وplan وstore_name
الدالة identify تجعل الزائر قبل التسجيل والمستخدم بعده شخصاً واحداً

ولماذا يهمنا هذا؟ لأن القمع يحسب الأشخاص وليس المعرفات، فإذا نسيت identify سوف يظهر لك الزائر في الخطوة الأولى بمعرف، ثم يظهر في خطوة التسجيل بمعرف آخر كأنه شخص جديد، فيبدو القمع كأن أحداً لم يكمل. وفي المقابل لا تستخدم البريد نفسه معرفاً، لأنه قد يتغير، واستخدم رقم المستخدم الثابت في قاعدة بياناتك، وتذكر أن تستدعي posthog.reset() عند تسجيل الخروج حتى لا تختلط أحداث شخصين على نفس الجهاز.

قمع التحويل Funnel: من الزيارة إلى أول فاتورة

ثلاث جلسات من المتصفح لا تكفي لرسم قمع له معنى، لذلك كتبنا سكربتاً صغيراً seed.mjs يولد بيانات تجريبية لـ 200 زائر على ستة أسابيع، يمر كل منهم بنفس الخطوات ويتوقف بعضهم في كل خطوة بنسب ثابتة، ويرسلها دفعة واحدة إلى /batch/ بتواريخ قديمة. والأرقام في هذا القسم والذي بعده من هذه البيانات المولدة، وليست من مستخدمين حقيقيين:

PH_HOST=https://analytics.example.com PH_TOKEN=phc_xxxxxxxx node seed.mjs
200 {"status":"Ok"} 477 events
{ '$pageview': 200, cta_clicked: 89, signed_up: 52, '$identify': 52, invoice_created: 84 }

بعد ذلك من Product analytics اختر New insight ثم Funnel، وأضف الخطوات بالترتيب: $pageview ثم cta_clicked ثم signed_up ثم invoice_created، واجعل نافذة التحويل Conversion Window أربعة عشر يوماً، والمدة آخر 45 يوماً:

تقرير Funnel في PostHog بعنوان عربي قمع التسجيل من الزيارة إلى أول فاتورة، بأربعة أعمدة: Pageview بـ 203 أشخاص، وcta_clicked بـ 92 شخصاً بنسبة 45.32%، وsigned_up بـ 55 شخصاً بنسبة 27.09%، وinvoice_created بـ 37 شخصاً بنسبة 18.23%، ونسبة التحويل الكلية 18.23% ومتوسط الوقت للتحويل 10 دقائق
القمع على البيانات المولدة: 18.23% من الزوار وصلوا إلى أول فاتورة

في التقرير أعلاه لاحظ التالي:

  • من 203 زائرين ضغط 92 على زر «ابدأ مجاناً»، وسجل 55، وأنشأ 37 فاتورة، أي أن أكبر خسارة في الخطوة الأولى، حيث غادر 111 زائراً دون أن يضغطوا الزر، وهنا تبدأ بتحسين الصفحة الرئيسية قبل نموذج التسجيل.
  • الرقم 203 وليس 200 لأن الجلسات الثلاث التي مررنا بها من المتصفح دخلت نفس القمع، وهذا يذكرك بأن تستبعد حسابات الفريق والاختبار من التقارير.
  • تحت كل خطوة الوقت الذي استغرقه الانتقال إليها، ومنه تعرف أي خطوة تأخذ وقتاً أطول من المتوقع.

وتعريف التفعيل Activation، أي متى نعتبر المستخدم وصل إلى قيمة المنتج، شرحناه في مقال التفعيل والاحتفاظ، وهنا اخترنا أول فاتورة كمثال.

الاحتفاظ Retention: هل يعود من سجل؟

القمع يخبرك كم شخصاً وصل، والاحتفاظ يخبرك كم شخصاً بقي. من New insight اختر Retention، واجعل الحدث الأول signed_up، والحدث الذي يعني العودة invoice_created، والفترة أسبوعية:

تقرير Retention في PostHog بعنوان عربي الاحتفاظ الأسبوعي من سجل ثم أنشأ فاتورة، فيه رسم خطوط لكل مجموعة وجدول مجموعات Cohorts أسبوعية من Sep 13 إلى Oct 24 بأحجام بين 7 و13، ومتوسط العودة في الأسبوع الأول 41.9%
كل صف مجموعة سجلت في نفس الأسبوع، وكل عمود نسبة من أنشأ فاتورة بعد عدد من الأسابيع

وكل صف في الجدول مجموعة Cohort من المستخدمين سجلوا في نفس الأسبوع، فمثلاً مجموعة 20 إلى 26 سبتمبر فيها 8 مستخدمين، وعاد 75% منهم لإنشاء فاتورة في الأسبوع الأول، أما الخلايا الفارغة في آخر كل صف فهي أسابيع لم تأت بعد. والذي تبحث عنه في الحياة الحقيقية هو هل يستقر المنحنى عند نسبة معينة أم ينزل إلى الصفر، لأن المنحنى الذي يستقر يعني أن هناك مجموعة وجدت قيمة في المنتج.

تسجيل الجلسات Session Replay

التسجيل متوقف افتراضياً في المشروع الجديد، وتفعله من Settings ثم Session replay ثم Record user sessions، وبعدها تحمل المكتبة ملف posthog-recorder.js من سيرفرك وتبدأ إرسال التسجيل إلى /s/. والتسجيل ليس فيديو، وإنما تغييرات الصفحة DOM مسجلة بمكتبة rrweb، لذلك يعاد بناء الصفحة في المتصفح عند المشاهدة:

مشغل التسجيلات في PostHog يعرض صفحة فاتورة جديدة من التطبيق التجريبي باتجاه من اليمين إلى اليسار، وحقل اسم العميل وحقل المبلغ مكتوب فيهما نجوم بدلاً من القيم، ومؤشر الماوس على زر إنشاء الفاتورة، وتحته رسالة تم إنشاء الفاتورة وإرسالها
إعادة تشغيل جلسة: الاتجاه العربي محفوظ، وقيم الحقول مخفية بالنجوم

قارن هذه الصورة بصورة الصفحة الأصلية في قسم إرسال الأحداث، فالصفحة أعيدت باتجاهها من اليمين إلى اليسار وبنصوصها العربية، أما «مؤسسة النخيل» و«1150» فظهرت نجوماً، لأن الإخفاء يتم في المتصفح قبل الإرسال، أي أن القيمة لا تصل إلى سيرفرك أصلاً. ولكن النصوص الثابتة في الصفحة تسجل كما هي، فإذا كانت صفحتك تعرض بيانات عميل خارج حقول الإدخال، مثل اسمه في رأس الصفحة، فأضف لها الكلاس ph-no-capture كما في صفحة الخصوصية.

الإطلاق التدريجي Feature Flags

مفهوم الـ Feature Flags والإطلاق التدريجي شرحناه في مقال Feature Flags، وهنا نطبقه: نريد أن نعرض شريط «خصم 20% على الخطة السنوية» في صفحة الأسعار لنصف المستخدمين فقط. من Feature flags أنشأنا Flag بالمفتاح annual-discount-banner وشرط واحد لـ 50% من المستخدمين:

صفحة Feature Flag في PostHog بالمفتاح annual-discount-banner ووصف عربي شريط خصم الخطة السنوية في صفحة الأسعار، والـ Flag مفعل، ونوعه Boolean، وشرط الإطلاق Set 1 يطابق 50% من المستخدمين
Flag من نوع Boolean مفعل لنصف المستخدمين

وفي صفحة الأسعار نقرأ الـ Flag بعد أن تحمل المكتبة قيم الـ Flags:

posthog.onFeatureFlags(function () {
  var on = posthog.isFeatureEnabled('annual-discount-banner');
  document.getElementById('banner').style.display = on ? 'block' : 'none';
});

ولكي نرى كيف يقسم PostHog المستخدمين، سألنا الـ API عن ستة مستخدمين بـ curl:

for u in user-sara user-omar user-lina user-khalid user-noura user-fahad; do
  printf "%s " $u
  curl -s https://analytics.example.com/flags/?v=2 -H 'Content-Type: application/json' \
    -d "{\"api_key\":\"phc_xxxxxxxx\",\"distinct_id\":\"$u\"}" \
  | jq -r '.flags."annual-discount-banner" | "\(.enabled) \(.reason.description)"'
done
user-sara false Out of rollout bound
user-omar true Matched condition set 1
user-lina false Out of rollout bound
user-khalid false Out of rollout bound
user-noura true Matched condition set 1
user-fahad false Out of rollout bound

في المخرج أعلاه لاحظ أن اثنين من ستة حصلا على الشريط وليس ثلاثة، والسبب أن الـ 50% نسبة على عدد كبير من المستخدمين وليست قسمة دقيقة لكل مجموعة صغيرة، فالقرار يحسب من هاش Hash لمعرف المستخدم ومفتاح الـ Flag. وهذا يعني أيضاً أن المستخدم نفسه يحصل على نفس النتيجة في كل زيارة ومن كل جهاز بعد identify، فعمر يرى الشريط دائماً وسارة لا تراه:

صفحة الأسعار في تطبيق فاتورتي كما يراها المستخدم عمر، وفي أعلاها شريط أخضر مكتوب فيه خصم 20% على الخطة السنوية، وتحته بطاقة الخطة المجانية 20 فاتورة شهرياً وبطاقة الخطة الاحترافية 49 ريالاً شهرياً
صفحة الأسعار لمستخدم داخل الـ 50%: الشريط ظاهر

وكل قراءة للـ Flag من المتصفح تسجل حدث $feature_flag_called، فتستطيع أن تقسم القمع بحسب قيمة الـ Flag وترى هل الشريط غير شيئاً. ولكن قياس أثر التغيير بدقة إحصائية موضوع المقال التالي اختبارات A/B مع GrowthBook، لأن التجارب Experiments في PostHog تعمل فوق نفس الـ Flags.

العربية واتجاه الواجهة RTL

جمعنا هنا ما وجدناه عن العربية في هذه التجربة:

ماذا اختبرناالنتيجة
اسم حدث عربي مثل «إرسال فاتورة»يحفظ ويظهر كما هو في Activity والتقارير
مفاتيح وقيم خصائص عربيةتحفظ، وتقرأ في HogQL بين علامتي `
عنوان تقرير ووصف Flag بالعربيةيظهر في اتجاهه الصحيح داخل الواجهة
تسجيل صفحة عربية RTLيعاد بناؤها باتجاهها ونصوصها
لغة الواجهة نفسهاإنجليزية فقط ومن اليسار إلى اليمين، ولم نجد إعداداً لتغييرها

ومع ذلك ننصحك بأن تكون أسماء الأحداث بالإنجليزية مثل invoice_created، وأن تكون القيم المعروضة للمستخدم بالعربية، والسبب أن الأسماء الإنجليزية أسهل في الكود وفي استعلامات SQL وفي البحث في الواجهة، ولا تحتاج علامات اقتباس خاصة. ولاحظ أيضاً أن العربية قد تفسد قبل أن تصل إلى PostHog إذا أرسلتها من طرفية لا تستخدم UTF-8، فقد أرسلنا حدثاً بخاصية عربية من طرفية Windows مباشرة في سطر الأمر فوصلت علامات استفهام ???????، وعندما وضعنا نفس الجسم في ملف وأرسلناه بـ --data-binary وصلت سليمة.

الخصوصية Privacy والبيانات الشخصية

تشغيل PostHog على سيرفرك يحل سؤال «أين البيانات»، ولكنه لا يحل سؤال «ماذا تجمع»، وتحليلات المنتج أخطر في ذلك من تحليلات الموقع، لأنك هنا تربط الأحداث ببريد شخص معروف وتسجل جلساته. وقد شرحنا موقف نظام حماية البيانات الشخصية من التتبع في مقارنة أدوات تحليلات الموقع ومن رسائل التسويق في قياس التسويق، وما يخص PostHog تحديداً:

  • المكتبة تحفظ معرف الزائر في localStorage وفي Cookie، وهذا ما رأيناه في الإعداد الذي طبعه وضع التصحيح posthog.debug(): "persistence":"localStorage+cookie"، فاذكر ذلك في سياسة الخصوصية.
  • لا ترسل في identify إلا ما تحتاجه في التحليل، فالخطة ونوع المتجر يكفيان غالباً، والبريد يمكن الوصول إليه من قاعدة بياناتك بالمعرف.
  • أبق إخفاء الحقول في التسجيلات، وأضف ph-no-capture للعناصر التي تعرض بيانات حساسة.
  • قاعدة GeoLite2 تحول عنوان IP إلى مدينة، وعنوان IP نفسه يحفظ مع الحدث في الخاصية $ip، وفي إعدادات المشروع خيار لتجاهله إذا لم تحتجه.

التحقق من النجاح

  • curl -s -o /dev/null -w '%{http_code}' https://analytics.example.com/_health يرجع 200.
  • docker compose ps يظهر كل الخدمات Up، ما عدا kafka-init الذي ينتهي بـ Exited (0) بعد إنشاء الـ Topics.
  • بعد زيارة التطبيق يظهر $pageview في Activity خلال ثوان، وبعد التسجيل تظهر الأحداث السابقة بنفس بريد المستخدم.
  • القمع والاحتفاظ يعرضان أرقاماً، والتسجيل يفتح بصفحتك وحقولها مخفية، والـ Flag يرجع true لبعض المستخدمين وfalse لغيرهم.

النسخ الاحتياطي Backup والاستعادة Restore

البيانات موزعة على عدة Volumes، أهمها postgres-data ففيه الحسابات والتقارير والـ Flags والأشخاص، وclickhouse-data ففيه الأحداث، وseaweedfs وobjectstorage-data ففيهما التسجيلات. وPostgreSQL تستطيع نسخها والنظام يعمل:

cd /opt/posthog
docker compose exec -T db pg_dump -U posthog -Fc posthog > /backup/posthog-pg-$(date +%F).dump

وقد أخذ هذا الأمر 4 ثوان وأنتج ملفاً بحجم 3.6 MB في تجربتنا. أما ClickHouse فأمر BACKUP فيه لا يعمل في هذه النسخة، لأنه يحتاج إعداد backups.allowed_path غير الموجود في ملفات الإعداد، والخطأ الذي يظهر هو The 'backups.allowed_path' configuration parameter is not set. لذلك الطريقة الأبسط أن توقف النظام وتنسخ الـ Volumes كلها مع ملف .env:

cd /opt/posthog
docker compose stop
for v in postgres-data clickhouse-data seaweedfs objectstorage-data; do
  docker run --rm -v posthog_$v:/data -v /backup:/backup alpine:3.20 \
    tar czf /backup/posthog-$v-$(date +%F).tgz -C /data .
done
cp .env /backup/posthog-env-$(date +%F)
docker compose start

وفي تجربتنا أخذ نسخ الـ Volumes الأربعة 33 ثانية بعد الإيقاف، وكان حجم ملف ClickHouse المضغوط 73 MB وملف PostgreSQL 24 MB. وفي الأوامر أعلاه لاحظ أن اسم الـ Volume يبدأ باسم المجلد posthog_ لأن Docker Compose يأخذ اسم المشروع من اسم المجلد، فتأكد منه بالأمر docker volume ls. ولاحظ أيضاً أن التشغيل بعد الإيقاف يأخذ حوالي 10 دقائق كما رأينا، فاختر وقتاً هادئاً، والأحداث التي ترسلها المتصفحات خلال التوقف تضيع، لأن خدمة الاستقبال نفسها متوقفة. وللاستعادة أنشئ الـ Volumes بنفس الأسماء، وفك الملفات فيها بنفس الطريقة، وضع ملف .env نفسه، ثم شغل docker compose up -d.

التحديث Upgrade إلى إصدار أحدث

الطريقة الرسمية هي سكربت upgrade-hobby من المستودع، ولكنه مكتوب لمن ثبت latest، حيث يدخل مجلد الكود وينفذ git pull --prune، وهذا الأمر لا يعمل إذا كان الكود مثبتاً على Commit كما فعلنا، والخطأ الذي سوف تراه هو:

You are not currently on a branch.
Please specify which branch you want to merge with.

والسكربت يعمل بـ set -e فيتوقف عند هذا السطر قبل أن يغير الـ Containers. لذلك نحدث بأنفسنا بنفس الخطوات التي يقوم بها السكربت، بعد أن نأخذ نسخة احتياطية كما تطلب صفحة الاستضافة الذاتية:

cd /opt/posthog
NEW=NEW_COMMIT_SHA
git -C posthog fetch origin
git -C posthog checkout "$NEW"
sed -i "s/^POSTHOG_APP_TAG=.*/POSTHOG_APP_TAG=$NEW/" .env
cp posthog/docker-compose.base.yml docker-compose.base.yml
cp posthog/docker-compose.hobby.yml docker-compose.yml
cp posthog/.env.services .env.services
docker compose pull
docker compose up -d

وقبل ذلك اقرأ bin/upgrade-hobby في الـ Commit الجديد، لأنه يحمل أحياناً خطوات خاصة ببعض التحديثات، مثل نقل الملفات من MinIO إلى SeaweedFS أو ترقية PostgreSQL من 12 إلى 15، فإذا وجدت خطوة منها تنطبق عليك فنفذها قبل up -d.

ولأن PostHog لا تصدر إصدارات مرقمة ولا ملاحظات إصدار Release Notes للاستضافة الذاتية، فالمرجع الوحيد لما تغير هو صفحة التغييرات وسجل الـ Commits، لذلك لا تحدث مباشرة على سيرفر الإنتاج، وإنما جرب الـ Commit الجديد أولاً على نسخة من البيانات، وراقب وقت الترحيلات، فإذا كان التحديث يحمل مئات الترحيلات فقد يتوقف الاستقبال وقتاً طويلاً كما رأينا في الإقلاع الأول. وتذكر أن خدمات posthog-node:latest و*:master سوف تتغير مع كل pull حتى لو لم تغير الـ Commit، وهذا سبب آخر لتسجيل الـ Digests قبل التحديث وبعده.

مشكلات شائعة وحلولها

الأحداث لا تصل من متصفح الاختبارات Headless

إذا كنت تختبر صفحاتك بمتصفح Headless مثل Playwright أو Puppeteer فسوف تجد أن الطلب الوحيد الذي يخرج هو /flags، ولا يخرج أي طلب إلى /e/. وعندما شغلنا posthog.debug() ظهرت الرسالة التالية:

[PostHog.js] Event "dbg_event" was not captured because the user agent looks like a bot. Set `opt_out_useragent_filter: true` to capture it.

والسبب أن الـ User Agent فيه كلمة HeadlessChrome، فتعتبره المكتبة روبوتاً وتتجاهل أحداثه، وهذا سلوك جيد في الإنتاج، ولكنه يربك من يختبر. لذلك في الاختبارات ضع User Agent عادياً، أو فعل opt_out_useragent_filter في بيئة الاختبار فقط.

الصفحة ترجع 502 بعد التثبيت

هذا يعني أن Caddy يعمل ولكن web لم يبدأ بعد، وغالباً لأن الترحيلات ما زالت تعمل، فتأكد بالأمر docker compose logs --tail 5 web، فإذا رأيت أسطر Applying فانتظر. وإذا رأيت خطأ بدلاً منها فهذا هو المكان الذي تبدأ منه، كما تقول صفحة الاستضافة الذاتية.

شريط أحمر «running in PRODUCTION mode without TLS»

يظهر هذا الشريط أسفل الواجهة إذا فتحتها بـ http://، وظهر لنا في التجربة لأننا شغلناها على جهاز محلي بدون نطاق، أما على السيرفر مع النطاق فإن Caddy يطلب الشهادة ويختفي الشريط، فإذا بقي فتأكد من سجل A ومن أن المنفذين 80 و443 مفتوحان.

لا توجد تسجيلات Recordings رغم أن الأحداث تصل

تأكد أولاً من تفعيل Record user sessions في الإعدادات، ثم من المتصفح استدع posthog.sessionRecordingStarted()، فإذا رجع true فالتسجيل يرسل، وابحث عنه برقم الجلسة الذي ترجعه posthog.get_session_id() في الرابط /replay/<session_id>. والتسجيلات تمر بخدمة ingestion-sessionreplay، فإذا لم تظهر فابحث في سجلاتها عن session_batch_recorder_flushing، لأنه السطر الذي يعني أنها حفظت دفعة من التسجيلات.

إزالة التجربة

إذا كنت جربت على جهازك وتريد إزالة كل شيء، ومنه البيانات، فاستخدم الأمر التالي، وانتبه إلى أن -v يحذف الـ Volumes:

cd /opt/posthog
docker compose down -v --rmi all

والخيار --rmi all يحذف الـ Images التي يستخدمها الملف، وهي حوالي 28 GB، فإذا كان على السيرفر نظام آخر يستخدم نفس Images مثل postgres:15.12-alpine فاحذف Images PostHog وحدها بدلاً منه.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • PostHog تقول صراحة إن الاستضافة الذاتية غير مدعومة، وبلا إصدارات مرقمة ولا CVE، وإن الميزات المدفوعة في السحابة فقط، وإصدارات hobby-* على GitHub هي أداة التثبيت وليست إصدارات للمنتج.
  • تستطيع تثبيت الكود بتمرير رقم الـ Commit إلى deploy-hobby، ولكن خدمات Node.js وRust وCaddy تبقى على latest وmaster، فسجل الـ Digests.
  • الحزمة 39 خدمة و28 GB من الـ Images، واستهلكت 18.5 GB من الذاكرة، وأخذ الإقلاع الأول 77 دقيقة بسبب 1215 ترحيلاً، وإعادة التشغيل حوالي 10 دقائق.
  • identify يجعل الزائر قبل التسجيل والمستخدم بعده شخصاً واحداً، وبدونه يفقد القمع معناه.
  • القمع والاحتفاظ وتسجيل الجلسات والـ Feature Flags عملت كلها، والتسجيل يخفي الحقول قبل الإرسال ويحفظ اتجاه الصفحة العربية.
  • الأحداث والخصائص العربية تعمل، والواجهة نفسها إنجليزية فقط، والأفضل أن تبقى أسماء الأحداث بالإنجليزية.

وأقرب المقالات إلى هذا الموضوع على الموقع:

دليلتحليلات الموقع دون Google Analytics: مقارنة عمليةشغلنا Umami وPlausible وMatomo على نفس الموقع وقارناها بـ Google Analytics في حجم السكربت والـ Cookies والعربية وملكية البيانات.دليلقياس التسويق: روابط UTM والإسناد Attributionكيف تعرف القناة التي جاءت بكل عميل: روابط UTM بقواعد تسمية ثابتة، وتحويل تعرفه أنت، ونموذج إسناد تفهم ما يحسبه.دليلاختبارات A/B بدقة مع GrowthBook على سيرفركنشغل GrowthBook مع PostgreSQL ونجري تجربة نعرف فرقها الحقيقي مسبقاً، لنتعلم قراءة فرصة الفوز والهامش وقيمة p دون أن نخدع أنفسنا.دليلأدوات النمو: Amplitude وMixpanel وبدائلها مفتوحة المصدرمقارنة أدوات تحليلات المنتج والتجارب والإحالة بالترخيص وآخر إصدار والسعر، مع اختبار فعلي للعربية في GrowthBook وUnleash.دليلالتفعيل Activation والاحتفاظ Retention: لماذا يغادر المستخدمون؟كيف تجد اللحظة التي يرى فيها المستخدم قيمة منتجك، وتقرأ منحنى الاحتفاظ والتسرب، مع تجربة تحسبها بـ Python وSQL.دليلمقاييس المنتج Product Metrics وكيف تعرف أن الميزة نجحتكيف تختار مقياساً رئيسياً North Star لمنتجك، وتبني قمع التحويل Funnel وجدول العودة Retention من أحداث تطبيقك بأدوات تستضيفها على سيرفرك.

سجل التحديثات

  • أكتوبر 2026: كتابة الدليل واختباره على PostHog Hobby من الـ Commit 3dcce7bc (أداة التثبيت hobby-20261025-002803) مع clickhouse-posthog 26.9.5.2 وpostgres:15.12-alpine وredpanda v25.1.9 وposthog-js 1.438.21 على Docker Engine 29.8.0، ببيانات تجريبية مولدة لـ 200 زائر.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.