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

المتطلبات
- سيرفر 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 السحابي Cloud | PostHog على سيرفرك 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 لميزات الذكاء الاصطناعي، واتركها فارغة إذا لم تحتجها.
وقد يتساءل البعض: ماذا يفعل هذا السكربت بالضبط؟ بعد أن قرأناه وجدنا أنه يقوم بما يلي:
- يثبت git وDocker وDocker Compose إذا لم تكن موجودة، ثم ينسخ مستودع PostHog ويثبته على الـ Commit.
- يكتب ملف
.envفيه أسرار عشوائية مثلPOSTHOG_SECRETوENCRYPTION_SALT_KEYS، مع النطاق والوسم، ولا يكتب فوقه إذا كان موجوداً، فاحفظ هذا الملف مع النسخ الاحتياطي لأن ضياعه يفسد البيانات المشفرة. - ينزل قاعدة مواقع GeoLite2 إلى المجلد
share/لتحويل عنوان IP إلى مدينة ودولة. - ينسخ
docker-compose.hobby.ymlإلىdocker-compose.yml، ثم يشغلdocker-compose up -d --pull always، وينتظر حتى يرجع/_healthالرمز 200 لمدة أقصاها 10 دقائق. - يرسل حدثين إلى PostHog Cloud عند بدء التثبيت وانتهائه، فيهما النطاق الذي أدخلته، فإذا كان اسم النطاق نفسه سراً فاحذف هذين السطرين من السكربت قبل تشغيله.
والملف الذي يشغله السكربت فيه 39 خدمة، وعندما فحصنا الـ Images فيه وجدنا أن تثبيت الـ Commit لا يثبت كل شيء:
| الخدمات | الـ Image | هل الإصدار ثابت؟ |
|---|---|---|
| web وworker وtemporal-django-worker | posthog/posthog:3dcce7bc… | نعم، بالـ Commit (9.2 GB) |
| plugins وخدمات ingestion وrecording-api | posthog/posthog-node:latest | لا، وسم latest (2.7 GB) |
| capture وfeature-flags وcymbal وlivestream وغيرها | ghcr.io/posthog/posthog/*:master | لا، وسم master |
| Caddy | caddy | لا، بدون وسم أصلاً |
| 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 Applyingweb-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% من نواة واحدة والنظام شبه خامل. والجدول التالي فيه أكبر المستهلكين:
| الخدمة | الذاكرة | دورها |
|---|---|---|
| worker | 7.1 GB | مهام Celery في الخلفية |
| web | 5.1 GB | الواجهة والـ API، بأربع عمليات Granian |
| temporal-django-worker | 1.4 GB | المهام الطويلة عبر Temporal |
| ClickHouse | 621 MB | تخزين الأحداث والاستعلام عليها |
| Kafka (Redpanda) | 621 MB | طابور الأحداث Queue |
| Elasticsearch | 557 MB | يعمل رغم أن ملف hobby يوقف استخدام Temporal له بـ ENABLE_ES=false |
| zookeeper | 415 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('إرسال فاتورة', { 'طريقة الإرسال': 'واتساب', 'المدينة': 'الرياض' });والصفحة التي اختبرنا عليها تبدو هكذا:

هل وصلت الأحداث إلى 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 تظهر الأسماء العربية كما هي، حتى في وصف النقرات التلقائية الذي يأخذ نص الزر:

أحداث من السيرفر 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' });
ولماذا يهمنا هذا؟ لأن القمع يحسب الأشخاص وليس المعرفات، فإذا نسيت identify سوف يظهر لك الزائر في الخطوة الأولى بمعرف، ثم يظهر في خطوة التسجيل بمعرف آخر كأنه شخص جديد، فيبدو القمع كأن أحداً لم يكمل. وفي المقابل لا تستخدم البريد نفسه معرفاً، لأنه قد يتغير، واستخدم رقم المستخدم الثابت في قاعدة بياناتك، وتذكر أن تستدعي posthog.reset() عند تسجيل الخروج حتى لا تختلط أحداث شخصين على نفس الجهاز.
قمع التحويل Funnel: من الزيارة إلى أول فاتورة
ثلاث جلسات من المتصفح لا تكفي لرسم قمع له معنى، لذلك كتبنا سكربتاً صغيراً seed.mjs يولد بيانات تجريبية لـ 200 زائر على ستة أسابيع، يمر كل منهم بنفس الخطوات ويتوقف بعضهم في كل خطوة بنسب ثابتة، ويرسلها دفعة واحدة إلى /batch/ بتواريخ قديمة. والأرقام في هذا القسم والذي بعده من هذه البيانات المولدة، وليست من مستخدمين حقيقيين:
PH_HOST=https://analytics.example.com PH_TOKEN=phc_xxxxxxxx node seed.mjs200 {"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 يوماً:

في التقرير أعلاه لاحظ التالي:
- من 203 زائرين ضغط 92 على زر «ابدأ مجاناً»، وسجل 55، وأنشأ 37 فاتورة، أي أن أكبر خسارة في الخطوة الأولى، حيث غادر 111 زائراً دون أن يضغطوا الزر، وهنا تبدأ بتحسين الصفحة الرئيسية قبل نموذج التسجيل.
- الرقم 203 وليس 200 لأن الجلسات الثلاث التي مررنا بها من المتصفح دخلت نفس القمع، وهذا يذكرك بأن تستبعد حسابات الفريق والاختبار من التقارير.
- تحت كل خطوة الوقت الذي استغرقه الانتقال إليها، ومنه تعرف أي خطوة تأخذ وقتاً أطول من المتوقع.
وتعريف التفعيل Activation، أي متى نعتبر المستخدم وصل إلى قيمة المنتج، شرحناه في مقال التفعيل والاحتفاظ، وهنا اخترنا أول فاتورة كمثال.
الاحتفاظ Retention: هل يعود من سجل؟
القمع يخبرك كم شخصاً وصل، والاحتفاظ يخبرك كم شخصاً بقي. من New insight اختر Retention، واجعل الحدث الأول signed_up، والحدث الذي يعني العودة invoice_created، والفترة أسبوعية:

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

قارن هذه الصورة بصورة الصفحة الأصلية في قسم إرسال الأحداث، فالصفحة أعيدت باتجاهها من اليمين إلى اليسار وبنصوصها العربية، أما «مؤسسة النخيل» و«1150» فظهرت نجوماً، لأن الإخفاء يتم في المتصفح قبل الإرسال، أي أن القيمة لا تصل إلى سيرفرك أصلاً. ولكن النصوص الثابتة في الصفحة تسجل كما هي، فإذا كانت صفحتك تعرض بيانات عميل خارج حقول الإدخال، مثل اسمه في رأس الصفحة، فأضف لها الكلاس ph-no-capture كما في صفحة الخصوصية.
الإطلاق التدريجي Feature Flags
مفهوم الـ Feature Flags والإطلاق التدريجي شرحناه في مقال Feature Flags، وهنا نطبقه: نريد أن نعرض شريط «خصم 20% على الخطة السنوية» في صفحة الأسعار لنصف المستخدمين فقط. من Feature flags أنشأنا Flag بالمفتاح annual-discount-banner وشرط واحد لـ 50% من المستخدمين:

وفي صفحة الأسعار نقرأ الـ 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)"'
doneuser-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، فعمر يرى الشريط دائماً وسارة لا تراه:

وكل قراءة للـ 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 زائر.