Free

自前 analytics の数字が嘘をついていた——Claude に直してもらう

自前 admin の数字が 5-10× ずれる:`server_side_visits` の膨張、分母不一致、`start_with?` が vanity URL を黙って落とす


私の admin ダッシュボードは Ahoy で自前計測している。最初に Ahoy を選んだのは「全部 Rails の中で完結する/外部依存を増やしたくない」という理由だった。あとから第三者ツールを横に並べた(Simple Analytics、Plausible、どれでもいい——ただの比較対象)。

ある日その 2 枚の表を見比べていたら、ユニーク訪問者数が 5〜10 倍 ずれていた。最初の本能的な反応:第三者側がデータを取りこぼしている。Claude に一瞥してもらった。判明したのは、自前ダッシュボードが嘘をついているのは 1 つの原因ではなく、3 種類の違うバグが積み重なっていたということ。この記事はその 3 つの罠について。

罠 1:server_side_visits = true

Ahoy はデフォルトでブラウザ側 JS による計測。私はそれを server_side_visits = true に切り替えていた——JS を実行しないリクエスト(ボット、API、cURL)を取りたかったので。

代償を見積もり切れていなかった:このフラグを ON にすると、すべての controller action が Visit 行を 1 件作る

  • ユーザーが入ってきて記事を 2 ページ読む(pageview 2 件)。途中で like API(API JSON)が走り、未ログインのリダイレクト(302 で sign_in に飛んで戻る)が起き、Turbo Frame の遅延読み込みサブリクエストも 1 本走る。実際の Visit 行数は余裕で 6〜10 件になる。
  • 「ユニーク訪問者」は元々 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

罠 2:bounce_rateengaged_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 つの比率はなぜ補集合じゃないんですか?」と訊いてきた。

罠 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") は問題なさそうに見える——サイトに 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 リストをリファクタリングするときに同じクラスのバグを踏む。

おまけ整理:backfill rake の秒境界 dedup

メインディッシュではないが、同じ 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.912:34:57.1to_i 後は 56 と 57 で一致せず、dedup が失敗、訪問が 2 件埋め戻される。

修正:1 秒の時間窓に切り替える:

return if existing_visits.any? { |v| (v.started_at - time).abs < 1.0 }

ついでに、1.hour.ago のランダムなサブ秒オフセットに依存していた、たまに落ちる spec も修正された。

本当の教訓:もう 1 つ時計が必要

時計が 1 つしかなければ、間違った数字はずっと間違ったまま居座れる。

私は admin ダッシュボードを何日も見ていたが、ユニーク訪問者が真の数字の 5〜10 倍であることに気付かなかった——参照系がなかったから。第三者ツールを横に並べた瞬間、突然「なぜこんなに差があるのか」という問いが生まれた。第三者ツールの具体的なブランドはどうでもいい——Simple Analytics、Plausible、ユニーク訪問者と pageview を出してくれる何かなら何でもいい。その存在意義は 校正であって、自前計測の置き換えではない。

Ahoy のように「ブラウザ側計測も server-side 計測も両方できる」ツールに特化して、私が踏んだ 3 つの罠は全部この記事にある:

  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_methodstart_with?vanity URL がないこと を前提にしている——あれば、厳密一致 + segment-anchored 正規表現に切り替えなければならない

データというものは、自分で計測して自分で見るとき、確認バイアスに非常に弱い。Claude に第三者の数字を引っ張ってきて比較させたら、3 種類の異なるエラーが同時に浮上した。

あなたの admin ダッシュボードもしばらくレビューされていないなら、横にもう 1 つ時計を並べてみてほしい。差が大きいほど、見つかるものも多い。