自前 admin の数字が 5-10× ずれる:`server_side_visits` の膨張、分母不一致、`start_with?` が vanity URL を黙って落とす
私の admin ダッシュボードは Ahoy で自前計測している。最初に Ahoy を選んだのは「全部 Rails の中で完結する/外部依存を増やしたくない」という理由だった。あとから第三者ツールを横に並べた(Simple Analytics、Plausible、どれでもいい——ただの比較対象)。
ある日その 2 枚の表を見比べていたら、ユニーク訪問者数が 5〜10 倍 ずれていた。最初の本能的な反応:第三者側がデータを取りこぼしている。Claude に一瞥してもらった。判明したのは、自前ダッシュボードが嘘をついているのは 1 つの原因ではなく、3 種類の違うバグが積み重なっていたということ。この記事はその 3 つの罠について。
server_side_visits = trueAhoy はデフォルトでブラウザ側 JS による計測。私はそれを server_side_visits = true に切り替えていた——JS を実行しないリクエスト(ボット、API、cURL)を取りたかったので。
代償を見積もり切れていなかった:このフラグを ON にすると、すべての controller action が Visit 行を 1 件作る。
Visit.distinct.count(:visitor_token) だった。この数字が真実の 5〜10 倍に膨れていた。第三者ツールは本物の $view イベントが発生した訪問しか数えないので、最初から膨らまない。
修正は複雑ではないが、考え方を変える必要がある:headline 数値は Visit 総数では使えない。「pageview が少なくとも 1 件発生した 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
bounce_rate と engaged_rate が 100% にならないこのバグは見た目はもっと地味だが、もっと背筋が寒くなる。
bounce_rate:1 ページだけで離れた訪問 / 少なくとも 1 ページ見た訪問。
engaged_rate:≥2 pageview または滞在 >30 秒の訪問 / 総訪問。
分母が 2 つで違う。
直感的にはこの 2 つは補集合のはず——訪問は bounce か engaged のどちらか、合わせて 100% に近いはず。でもダッシュボードは bounce 47% + engaged 31% = 78% を表示していた。残りの 22% はどこへ消えた?
罠 1 の「pageview を出さなかった Visit」のバケツに行っていた。それらは engaged_rate の分母(総訪問)には入ったが、bounce_rate の分母(pageview-visits)からは外れていた。
修正:両方の分母を visits_with_any_pageview に揃える。すると 2 つの比率は本当に 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)
私はダッシュボードを何日も眺めていたが、この 2 つの数字が 100% にならないことに気付かなかった——別々に見ていたから。それを並べて置いた人は Claude で、admin オーバービューコンポーネントを書いている最中に「この 2 つの比率はなぜ補集合じゃないんですか?」と訊いてきた。
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 プロフィールがあることを思い出すまでは。各ユーザーのホームは /<username>。
つまり:
/update(ユーザー)が除外される(/up で始まるから)/administrator が除外される/jobsworth が除外される/cabletv が除外される/ahoyo も、もしそのユーザーがいたら除外されるこれらのユーザーの pageview は Ahoy に入らない。自分のプロフィールを見ても、他人のを見ても、リロードしても、コメントしても、すべてのページアクセスが黙って捨てられる。ダッシュボードから見ると、このユーザーたちはサイトを使ったことがないように見える。
第三者ツールはこの除外リストを共有していないので、このユーザーたちが見える。これが 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 で一緒に修正した。
過去の sign-in イベントを訪問として埋め戻す rake タスクがある。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 を素早く 2 回クリック(ダブルクリックなど)。2 つの時刻は 12:34:56.9 と 12:34:57.1。to_i 後は 56 と 57 で一致せず、dedup が失敗、訪問が 2 件埋め戻される。
修正:1 秒の時間窓に切り替える:
return if existing_visits.any? { |v| (v.started_at - time).abs < 1.0 }
ついでに、1.hour.ago のランダムなサブ秒オフセットに依存していた、たまに落ちる spec も修正された。
時計が 1 つしかなければ、間違った数字はずっと間違ったまま居座れる。
私は admin ダッシュボードを何日も見ていたが、ユニーク訪問者が真の数字の 5〜10 倍であることに気付かなかった——参照系がなかったから。第三者ツールを横に並べた瞬間、突然「なぜこんなに差があるのか」という問いが生まれた。第三者ツールの具体的なブランドはどうでもいい——Simple Analytics、Plausible、ユニーク訪問者と pageview を出してくれる何かなら何でもいい。その存在意義は 校正であって、自前計測の置き換えではない。
Ahoy のように「ブラウザ側計測も server-side 計測も両方できる」ツールに特化して、私が踏んだ 3 つの罠は全部この記事にある:
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 正規表現に切り替えなければならないデータというものは、自分で計測して自分で見るとき、確認バイアスに非常に弱い。Claude に第三者の数字を引っ張ってきて比較させたら、3 種類の異なるエラーが同時に浮上した。
あなたの admin ダッシュボードもしばらくレビューされていないなら、横にもう 1 つ時計を並べてみてほしい。差が大きいほど、見つかるものも多い。