免费

让 Claude 修自家 analytics 里说谎的数字

自建后台数字差 5-10 倍——`server_side_visits` 膨胀、分母错位、`start_with?` 把 vanity URL 全静默排除


我有一个 admin 后台用 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)、一次未登录跳转(302 到 sign_in 然后回来)、加上 Turbo Frame 懒加载第二页内容的子请求,实际 Visit 行数能轻松到 6~10 条。
  • "独立访客"原本 = 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

陷阱 2:bounce_rate 和 engaged_rate 永远不等于 100%

这个 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 总览组件的时候问我:"这两个比例为什么不互补?"

陷阱 3: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 列表的时候还会再栽。

顺手清理:backfill rake 任务的秒级边界

这个不算主菜,但同一个 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.912: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 埋点"的工具,三个最容易踩的坑都在这一篇里了:

  1. server_side_visits = true 之后,headline 数字必须按"产生 pageview 的 Visit"算,不能按 Visit 总数
  2. bounce / engaged 这种比例的分母必须显式对齐,spec 写 expect(bounce + engaged).to be_within(1.0).of(100)
  3. exclude_method 里的路径用 start_with? 时假设你没有 vanity URL——一旦有,必须改成精确匹配 + segment-anchored 正则

数据这种东西,自己埋自己看的时候很容易 confirmation bias。让 Claude 把第三方那块的数字也拿过来比,三类不同的错就一起浮出水面了。

如果你的 admin dashboard 也是几个月没人重新审过,挂个第二块表对一下。差距越大,问题越多。