Free

دع Claude يصحّح الأرقام الكاذبة في نظام التحليلات الذي صنعته بنفسك

أرقام لوحة admin محلّية مخطئة بـ5-10×: انتفاخ `server_side_visits`، تباين المقامات، `start_with?` يبتلع vanity URLs


لديّ لوحة تحكّم admin مع تتبّع ذاتي عبر Ahoy. اخترت Ahoy في البداية لأنّني أردت أن يكون كلّ شيء داخل Rails ولم أرغب بإضافة اعتماد على طرف ثالث. لاحقاً علّقت إلى جواره أداةً من طرف ثالث (Simple Analytics أو Plausible أو أيّ منها — الفكرة فقط أن يكون هناك مرجع للمقارنة).

في يوم ما حدّقت في الجدولين جنباً إلى جنب، فلاحظت أنّ عدد الزوّار الفريدين يختلف بمقدار 5 إلى 10 أضعاف. ردّ فعلي الأوّل: أداة الطرف الثالث تفقد بيانات. طلبت من Claude أن يلقي نظرةً عابرة. تبيّن أنّ لوحتي لا تكذب لسبب واحد — بل لثلاث علل مختلفة متراكمة فوق بعضها. هذا المقال عن تلك الفخاخ الثلاثة.

الفخّ 1: server_side_visits = true

Ahoy افتراضاً يتتبّع من جانب المتصفّح عبر JS. كنت قد فعّلت server_side_visits = true لأنّني أردت التقاط الطلبات التي لا تشغّل JS — البوتات، طلبات API، cURL.

ما لم أحسبه: بمجرّد تفعيل هذا المفتاح، كلّ controller action يُنشئ سطر Visit.

  • يدخل مستخدم، يقرأ مقالين (2 pageview)، يضغط زرّ like (API JSON)، يحدث redirect إلى sign in ثمّ يعود، إضافةً إلى sub-request لـTurbo Frame يحمّل تكاسلياً الصفحة التالية. عدد سطور Visit الفعلي: 6 إلى 10 بسهولة.
  • "الزوّار الفريدون" كانوا = Visit.distinct.count(:visitor_token). هذا الرقم انتفخ 5–10 أضعاف الحقيقة.

أداة الطرف الثالث لا تحسب إلا الزيارات التي أصدرت حدث $view حقيقياً، فلم تنتفخ أبداً.

الإصلاح ليس معقّداً، لكن الإطار يجب أن يتغيّر: لا يمكن استخدام إجمالي Visits لرقم العنوان، يجب استخدام "Visits التي أنتجت ولو pageview واحد على الأقلّ". أمّا ما تبقّى من API / redirect / sub-request لـTurbo Frame فيوضع في خانة منفصلة، noise_visits_count — ليست زواراً، لكنّها حركة مرور حقيقية، وعرضها مفيد.

visits_with_any_pageview = Ahoy::Event
  .where(name: "$view", time: range)
  .joins(:visit)
  .distinct
  .pluck("ahoy_visits.id")

# رقم العنوان يحسب هذا فقط
unique_visitors = Ahoy::Visit.where(id: visits_with_any_pageview).distinct.count(:visitor_token)
noise_visits_count = total_visits - visits_with_any_pageview.size

الفخّ 2: bounce_rate وengaged_rate لا يجمعان إلى 100% أبداً

هذه العلّة تبدو أدقّ، لكنّها التي تُقشعرّ منها الظهر أكثر.

bounce_rate: زيارات شاهدت صفحة واحدة فقط ثمّ غادرت / زيارات شاهدت صفحة واحدة على الأقلّ.
engaged_rate: زيارات بـ≥2 pageview أو إقامة >30 ثانية / إجمالي الزيارات.

مقامان مختلفان.

حدسياً يجب أن يكون النسبتان متمّمتين — الزيارة إمّا bounce وإمّا engaged، وحاصل جمعهما ≈100%. لكنّ اللوحة كانت تُظهر bounce 47% + engaged 31% = 78%. أين ذهب الـ22% المتبقّي؟

ذهب إلى دلو "Visits التي لم تنتج أيّ pageview" من الفخّ 1. سقطت في مقام engaged_rate (إجمالي الزيارات) لكنّها استُبعدت من مقام bounce_rate (pageview-visits).

الإصلاح: محاذاة المقامَين على visits_with_any_pageview. عندئذٍ تجمع النسبتان فعلاً ≈100%، ويُثبَّت ذلك في spec.

denominator = visits_with_any_pageview.size
bounce_rate  = bounced_visits.to_f  / denominator * 100
engaged_rate = engaged_visits.to_f  / denominator * 100
# spec: expect(bounce_rate + engaged_rate).to be_within(1.0).of(100)

كنت أنظر إلى اللوحة منذ أيّام دون أن ألاحظ أنّ الرقمين لا يجمعان إلى 100% — لأنّني كنت أنظر إليهما منفصلين. مَن وضعهما جنباً إلى جنب هو Claude، وأنا أُجمِّع مكوّن overview للأدمن، فسألني: «لماذا هاتان النسبتان غير متمّمتين؟».

الفخّ 3: start_with?("/up") يُسقط مستخدمين بأكملهم في صمت

هذا أكثرها سُخفاً.

في config/initializers/ahoy.rb يوجد Ahoy.exclude_method يصفّي المسارات التي لا أرغب بإحصائها — health checks، assets، admin، jobs داخلية. الكتابة الأصلية:

Ahoy.exclude_method = lambda do |controller, request|
  path = request.path
  path.start_with?("/rails/", "/cable", "/jobs", "/up", "/assets", "/admin", "/ahoy") ||
    path.end_with?(".json", ".xml", ".gz") ||
    path.start_with?("/sitemap")
end

"/up" هو نقطة health check في Rails 8. start_with?("/up") تبدو سليمة — حتّى تتذكّر أنّ الموقع يحوي ملفّات شخصية بـvanity URL: صفحة كلّ مستخدم هي /<username>.

أي:

  • /update (المستخدم) يُستبعد — يبدأ بـ/up
  • /administrator يُستبعد
  • /jobsworth يُستبعد
  • /cabletv يُستبعد
  • /ahoyo لو وُجد ذلك المستخدم لاستُبعد أيضاً

pageview هؤلاء المستخدمين لا يدخل Ahoy إطلاقاً. يشاهدون ملفّاتهم، يشاهدون ملفّات الآخرين، يحدّثون الصفحة، يتركون تعليقات، وكلّ مشاهدة صفحة تُلقى صامتةً. من منظور اللوحة، هؤلاء المستخدمون كأنّهم لم يستخدموا الموقع إطلاقاً.

أداة الطرف الثالث لا تشترك في قائمة الاستبعاد هذه، لذا كانت تراهم. وذلك يفسّر جزءاً من الفجوة 5–10 أضعاف.

الإصلاح: مساواة دقيقة لـ/up؛ regex مرتكز على segment لبادئات الـnamespace — /admin/ يُستبعد، أمّا /administrator فلا.

NAMESPACE_PREFIX_PATTERN = %r{\A/(cable|jobs|assets|admin|ahoy)(/|\z)}.freeze

Ahoy.exclude_method = lambda do |controller, request|
  path = request.path
  path == "/up" ||
    path.start_with?("/rails/", "/sitemap") ||
    NAMESPACE_PREFIX_PATTERN.match?(path) ||
    path.end_with?(".json", ".xml", ".gz")
end

الـspec يحصل على حالات حدّ المسار: /up يُستبعد ولا يُستبعد /update، /admin/foo يُستبعد ولا يُستبعد /administrator. بدون هذه الـspecs، في المرّة القادمة التي يُعاد فيها هيكلة قائمة الاستبعاد، سيقع المرء في الفئة نفسها من الأخطاء.

تنظيف إضافي: dedup حدّ الثانية في rake backfill

ليس الطبق الرئيسي، لكنّ الـPR ذاته نظّفه أيضاً.

ثمّة rake task يُعيد ملء أحداث sign-in تاريخية كزيارات. منطق dedup كان يضع الطوابع الزمنية في دلو ثانية عبر Time#to_i:

existing_buckets = existing_visits.map { |v| v.started_at.to_i }
return if existing_buckets.include?(time.to_i)

المشكلة: المستخدم ينقر sign-in نقرتين سريعتين (double-click). الطابعان 12:34:56.9 و12:34:57.1. بعد to_i يصبحان 56 و57، لا يتطابقان، يفشل dedup، فتُضاف زيارتان عبر backfill.

الإصلاح: التحوّل إلى نافذة زمنية مدّتها ثانية واحدة:

return if existing_visits.any? { |v| (v.started_at - time).abs < 1.0 }

عرضاً، أصلح هذا أيضاً spec يرتعش أحياناً لاعتماده على إزاحة دون الثانية العشوائية لـ1.hour.ago.

الدرس الفعلي: تحتاج إلى ساعة ثانية

إن نظرت إلى ساعة واحدة فقط، فالرقم الخاطئ قد يبقى خاطئاً إلى الأبد.

كنت أحدّق في لوحة الأدمن منذ أيّام دون أن ألاحظ أنّ "الزوّار الفريدين" يفوقون الحقيقة بـ5–10 أضعاف — لأنّه لم يكن لديّ مرجع. ما إن علّقت أداة الطرف الثالث إلى جواره، حتّى نشأ سؤال جديد من العدم: «لماذا هذه الفجوة كبيرة إلى هذه الدرجة؟». العلامة التجارية المحدّدة لأداة الطرف الثالث لا تهمّ — Simple Analytics، Plausible، أيّ شيء يعطيك زواراً فريدين وعدد pageview يصلح. مهمّتها هي المعايرة، لا استبدال تتبّعك الذاتي.

خصوصاً مع Ahoy التي تتيح تتبّع المتصفّح والـserver-side معاً، فالفخاخ الثلاثة التي وقعت فيها كلّها في هذا المقال:

  1. بعد server_side_visits = true، يجب أن يحسب رقم العنوان "Visits التي أنتجت pageview"، لا إجمالي Visits.
  2. النسب من نوع bounce / engaged يجب أن تُحاذى مقاماتها بصريح العبارة — اكتب الـspec: expect(bounce + engaged).to be_within(1.0).of(100).
  3. start_with? في exclude_method يفترض ضمنياً أنّه ليست لديك vanity URLs. إن كانت لديك، انتقل إلى مساواة دقيقة + regex مرتكز على segment.

التحليلات المستضافة ذاتياً عرضةٌ بشكل غير اعتيادي لـconfirmation bias — أنت كتبت المتعقّب، أنت تثق بالمتعقّب. ترك Claude يجلب أرقام الطرف الثالث ويقارنها جعل الأخطاء الثلاثة المختلفة تطفو دفعةً واحدة.

إن كانت لوحة الأدمن لديك لم تُراجَع منذ بضعة أيّام، فعلِّق إلى جوارها ساعةً ثانية. كلّما اتّسعت الفجوة، اكتشفت أكثر.