מספרי admin ביתי סוטים פי 5-10: ניפוח `server_side_visits`, מכנים לא מיושרים, `start_with?` בולע vanity URLs
יש לי דשבורד אדמין עם trackingעצמי דרך Ahoy. בחרתי ב-Ahoy בהתחלה כי רציתי שהכל יהיה בתוך Rails ולא רציתי להוסיף תלות חיצונית. אחר כך תליתי לידו כלי צד שלישי (Simple Analytics, Plausible, מה שתבחרו — המטרה הייתה רק לקבל נקודת השוואה).
יום אחד הסתכלתי על שתי הטבלאות זו לצד זו וראיתי שמספר המבקרים הייחודיים נבדל פי 5 עד 10. תגובה ראשונה אינסטינקטיבית: כלי צד שלישי מפספס נתונים. ביקשתי מ-Claude שיציץ. התברר שהדשבורד שלי לא משקר מסיבה אחת — היו שלושה באגים שונים שערומים זה על זה. הפוסט הזה הוא על שלוש המלכודות.
server_side_visits = trueברירת המחדל של Ahoy היא tracking בצד הדפדפן דרך JS. אני הפעלתי server_side_visits = true כי רציתי לתפוס גם בקשות שלא מריצות JS — בוטים, קריאות API, cURL.
מה שלא חישבתי: כשהדגל הזה דולק, כל controller action יוצר שורת Visit.
Visit.distinct.count(:visitor_token). המספר הזה היה מנופח פי 5–10 מהאמת.כלי צד שלישי סופר רק ביקורים שירו אירוע $view אמיתי, אז הוא לא התנפח אף פעם.
התיקון לא מסובך, אבל המסגור צריך להשתנות: מספר הכותרת לא יכול להשתמש בסך כל ה-Visits, חייב להשתמש ב"Visits שהפיקו לפחות pageview אחד". יתר ה-API / הפניות / 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
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 אדמין, והוא שאל אותי: "למה שני היחסים האלה לא משלימים?"
start_with?("/up") משליך משתמשים שלמים בשקטזו האבסורדית ביותר.
ב-config/initializers/ahoy.rb יש Ahoy.exclude_method שמסנן נתיבים שאני לא רוצה לספור — health check, 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" הוא endpoint של 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 לא. בלי ה-spec הזה, מי שיעשה refactor לרשימה הבאה ייפול באותה מחלקת באג.
לא המנה העיקרית, אבל אותו PR ניקה גם את זה.
יש rake task שעושה backfill לאירועי 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 flaky שתלוי ב-offset תת-שני אקראי של 1.hour.ago.
אם מסתכלים רק על שעון אחד, המספר השגוי יכול להישאר שגוי לנצח.
הסתכלתי על דשבורד האדמין שלי כבר כמה ימים בלי לשים לב ש"מבקרים ייחודיים" עומדים על פי 5–10 מעבר למספר האמיתי — כי לא היה לי נקודת ייחוס. ברגע שתליתי את כלי הצד השלישי לידו, צצה מאי-שם שאלה: "למה הפער הזה כל כך גדול?". המותג הספציפי של כלי הצד השלישי לא משנה — Simple Analytics, Plausible, כל דבר שייתן לכם מבקרים ייחודיים ו-pageview יתאים. תפקידו הוא כיול, לא תחליף לטראקינג שלכם.
ספציפית ל-Ahoy, שמסוגל לעשות tracking גם בצד הדפדפן וגם server-side, שלוש המלכודות שדרכתי בהן כולן כאן:
server_side_visits = true, מספר הכותרת חייב לספור "Visits שהפיקו pageview", לא סך כל ה-Visits.expect(bounce + engaged).to be_within(1.0).of(100).start_with? ב-exclude_method מניח בשתיקה שאין לכם vanity URLs. אם יש — תעברו לשוויון מדויק בתוספת regex מעוגן-segment.analytics בהוסטינג עצמי פגיע באופן יוצא דופן ל-confirmation bias — אתם כתבתם את הטראקר, אתם סומכים על הטראקר. לתת ל-Claude למשוך את מספרי הצד השלישי ולהשוות גרם לשלוש השגיאות לעלות יחד.
אם דשבורד האדמין שלכם לא נסקר זמה ימים, תלו לידו שעון שני. ככל שהפער גדול יותר, כך תמצאו יותר.