自建后台数字差 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") 看起来没问题——直到你想起 pickful 上有 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 也是几个月没人重新审过,挂个第二块表对一下。差距越大,问题越多。