Free

직접 만든 analytics가 거짓말한다 — Claude에게 고쳐달라고 하자

자작 admin 숫자가 5-10× 어긋난다: `server_side_visits` 인플레, 분모 불일치, `start_with?`가 vanity URL을 조용히 누락


내 admin 대시보드는 Ahoy로 직접 계측한다. 처음 Ahoy를 고른 이유는 "전부 Rails 안에서 끝낸다 / 외부 의존성을 늘리고 싶지 않다"는 것. 나중에 제3자 도구 한 개를 옆에 붙였다(Simple Analytics, Plausible, 어느 쪽이든 — 단지 비교 대상으로).

어느 날 두 표를 나란히 놓고 보니 고유 방문자 수가 5~10배 차이가 났다. 첫 본능적 반응: 제3자 쪽이 데이터를 못 잡고 있다. Claude에게 한 번 봐달라고 했다. 알고 보니 내 대시보드가 거짓말하는 이유는 한 가지가 아니라 — 세 가지 다른 버그가 겹쳐 쌓여 있었다. 이 글은 그 세 가지 함정에 대한 것이다.

함정 1: server_side_visits = true

Ahoy의 기본은 브라우저 사이드 JS 계측. 나는 그것을 server_side_visits = true로 켰다 — JS를 실행하지 않는 요청(봇, API, cURL)을 잡고 싶었기 때문.

대가를 제대로 계산하지 못했다: 이 플래그를 켜는 순간, 모든 controller action이 Visit 행을 한 줄씩 만든다.

  • 사용자가 들어와서 글 두 페이지를 본다(pageview 2회). 중간에 like API(API JSON)가 호출되고, 미로그인 리다이렉트(302로 sign_in으로 갔다 돌아옴)가 발생하고, Turbo Frame 지연 로드 서브 요청도 한 번 뜬다. 실제 Visit 행 수는 여유 있게 6~10개.
  • "고유 방문자"는 원래 Visit.distinct.count(:visitor_token). 이 숫자가 진짜의 5~10배로 부풀어 있었다.

제3자 도구는 진짜 $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_rateengaged_rate가 100%로 안 더해진다

이 버그는 더 미묘해 보이지만 더 등골이 서늘하다.

bounce_rate: 한 페이지만 보고 떠난 방문 / 적어도 한 페이지를 본 방문.
engaged_rate: ≥2 pageview 또는 체류 >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.rbAhoy.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에 들어오지 않는다. 자기 프로필을 보든, 남의 프로필을 보든, 새로고침을 하든, 댓글을 남기든, 모든 페이지 접근이 조용히 버려진다. 대시보드 시점에서 이 사용자들은 사이트를 한 번도 안 쓴 사람처럼 보인다.

제3자 도구는 이 제외 목록을 공유하지 않으므로 이 사용자들을 본다. 이게 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 이벤트를 visit으로 백필하는 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을 빠르게 두 번 클릭(더블클릭). 두 시각은 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 }

1.hour.ago의 무작위 서브초 오프셋에 의존하던, 가끔 깨지던 spec도 함께 고쳐졌다.

진짜 교훈: 두 번째 시계가 필요하다

시계가 하나뿐이면 틀린 숫자는 영원히 틀린 채로 남을 수 있다.

나는 며칠 동안 admin 대시보드를 봤지만 고유 방문자가 진짜의 5~10배라는 걸 눈치채지 못했다 — 참조계가 없었기 때문에. 제3자 도구를 옆에 붙이는 순간, "왜 차이가 이렇게 큰가"라는 질문이 갑자기 생겼다. 제3자 도구의 구체적인 브랜드는 중요하지 않다 — 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_methodstart_with?vanity URL이 없다는 것을 전제로 한다 — 있으면 정확 일치 + segment-anchored 정규식으로 바꿔야 한다

데이터라는 건 직접 계측해서 직접 볼 때 확인 편향에 매우 취약하다. Claude에게 제3자 숫자를 끌어와서 비교하게 했더니, 세 가지 다른 오류가 함께 떠올랐다.

당신의 admin dashboard도 며칠 동안 아무도 다시 보지 않았다면, 옆에 두 번째 시계를 붙여보라. 차이가 클수록 발견할 게 많다.