自建後台數字差 5-10 倍——`server_side_visits` 膨脹、分母錯位、`start_with?` 把 vanity URL 全靜默排除
我有一個 admin 後台用 Ahoy 自己埋點。最早是衝著「全在 Rails 裡、不依賴外面」才裝的,後來又在旁邊掛了一塊第三方表(Simple Analytics、Plausible 這一類,挑哪家都行,目的就是當個對照)。
某天我盯著兩塊表看,發現獨立訪客數差了 5 到 10 倍。一開始的本能反應是:埋點漏了,第三方那塊拿不到完整資料。讓 Claude 順手掃一眼,結果發現自家這塊數字撒謊不是一個原因——是三類不同的錯疊在一起。這一篇就是這三個陷阱。
server_side_visits = trueAhoy 預設是瀏覽器側 JS 埋點。但我打開了 server_side_visits = true,因為我想抓那些沒跑 JS 的請求(爬蟲、API、cURL)。
代價我沒算清楚:這個開關一打,每個 controller action 都開一條 Visit。
Visit.distinct.count(:visitor_token)。這個數字直接乘上去 5~10 倍。第三方那塊只統計真正產生 $view 事件的瀏覽,所以從來沒膨脹。
修法不複雜,但思路上得換:headline 數字不能用 Visit 總數,必須用「產生了至少一次 pageview 的 Visit」。剩下那些 API / redirect / Turbo Frame 子請求歸到一個 noise_visits_count 單獨顯示——它們不是訪客,但也是真實流量,留著看也有用。
visits_with_any_pageview = Ahoy::Event
.where(name: "$view", time: range)
.joins(:visit)
.distinct
.pluck("ahoy_visits.id")
# headline 只算這部分
unique_visitors = Ahoy::Visit.where(id: visits_with_any_pageview).distinct.count(:visitor_token)
noise_visits_count = total_visits - visits_with_any_pageview.size
這個 bug 看起來更「細」,但更讓人後背涼。
bounce_rate:只看了一頁就走的訪問 / 至少看了一頁的訪問。
engaged_rate:看了 ≥2 頁 或 停留 >30 秒的訪問 / 總訪問。
兩個分母不一樣。
直覺上這兩個比例應該剛好是補集——要麼 bounce、要麼 engaged,加起來約 100%。後台顯示卻是 bounce 47% + engaged 31% = 78%。剩下的 22% 去哪了?
去了陷阱 1 裡那批「沒產生 pageview 的 Visit」。它們落進了 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,它在寫一個 admin 總覽元件的時候問我:「這兩個比例為什麼不互補?」
start_with?("/up") 把整批使用者靜默排除這是最離譜的一個。
config/initializers/ahoy.rb 裡有一個 Ahoy.exclude_method,過濾掉不想統計的路徑——健康檢查、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" 是 Rails 8 的健康檢查端點。start_with?("/up") 看起來沒問題——直到你想起站上有 vanity username profile:每個使用者的主頁是 /<username>。
那麼:
/update 這個使用者被排除(因為以 /up 開頭)/administrator 被排除/jobsworth 被排除/cabletv 被排除/ahoyo 被排除(如果有這個使用者)這些使用者的 pageview 完全不進 Ahoy。他們看自己主頁、看別人主頁、重新整理、留言,所有頁面訪問都被靜默丟棄。從後台看上去,這批使用者像是從來沒用過這個站。
第三方那塊沒有這個 exclude 清單,所以它能看到這些使用者。這就是 5~10 倍差距裡的一部分。
修法:/up 用精確匹配;其餘 namespace 前綴用 segment-anchored 正則,確保 /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 不寫,下一次重構 exclude 清單的時候還會再栽。
這個不算主菜,但同一個 PR 裡順手修了。
有一個 rake 任務負責把歷史 sign-in 事件回填成 visit。dedup 邏輯用 Time#to_i 把時間戳塞進秒級的 bucket:
existing_buckets = existing_visits.map { |v| v.started_at.to_i }
return if existing_buckets.include?(time.to_i)
問題:使用者快速點登入兩次(比如雙擊),兩次時間分別是 12:34:56.9 和 12:34:57.1。兩個 to_i 後變成 56 和 57,不相等,dedup 失效,回填出兩條 visit。
修法:換成 1 秒的時間窗:
return if existing_visits.any? { |v| (v.started_at - time).abs < 1.0 }
順帶還修了一個偶爾會失敗的 spec——它依賴 1.hour.ago 的隨機亞秒偏移。
如果只看一塊表,錯的數字可以一直錯下去。
我看 admin 後台看了幾天,沒發現獨立訪客是真實數字的 5~10 倍——因為我沒有別的參照系。直到把第三方那塊掛上來,才突然多出一道「為什麼差這麼多」的問題。那塊第三方表的具體牌子無所謂,Simple Analytics、Plausible、隨便哪個能給你獨立訪客和 pageview 數的工具都可以;它存在的意義是校準,不是替代你自家的埋點。
具體到 Ahoy 這種「既能瀏覽器埋點又能 server-side 埋點」的工具,三個最容易踩的坑都在這一篇裡了:
server_side_visits = true 之後,headline 數字必須按「產生 pageview 的 Visit」算,不能按 Visit 總數expect(bounce + engaged).to be_within(1.0).of(100)exclude_method 裡的路徑用 start_with? 時假設你沒有 vanity URL——一旦有,必須改成精確匹配 + segment-anchored 正則資料這種東西,自己埋自己看的時候很容易 confirmation bias。讓 Claude 把第三方那塊的數字也拿過來比,三類不同的錯就一起浮出水面了。
如果你的 admin dashboard 也是好幾天沒人重新審過,掛個第二塊表對一下。差距越大,問題越多。