Files
sori.studio/plugins/site-analytics.client.js
zenn c43873ce5f v1.3.5: 관리자 로그인·대시보드 차트·통계 보관 정리
운영 HTTP에서 관리자 세션이 유지되지 않던 문제를 쿠키 공통화로 수정하고, 통계 클라이언트 분리·조회 오류·기간별 차트를 보강했다. 방문자 해시는 32일 초과분만 정리하고 일별 집계는 누적 보관한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 13:54:38 +09:00

368 lines
8.6 KiB
JavaScript

import {
ANALYTICS_MAX_DURATION_SECONDS,
isTrackableAnalyticsPath
} from '../lib/analytics-shared.js'
/** @type {string} 탭 단위 클라이언트 세션 storage 키 */
const CLIENT_SESSION_STORAGE_KEY = 'sori_analytics_client_session'
/** @type {number} 읽음 판정 최소 체류 시간(ms) */
const READ_MIN_DURATION_MS = 15000
/** @type {number} 읽음 판정 최소 스크롤 비율 */
const READ_MIN_SCROLL_RATIO = 0.5
/** @type {number} heartbeat 전송 간격(ms) */
const HEARTBEAT_INTERVAL_MS = 20000
/** @type {Set<string>} 세션 내 전송 완료된 pageview 키 */
const sentViewKeys = new Set()
/** @type {Set<string>} 세션 내 전송 완료된 read slug */
const sentReadSlugs = new Set()
/** @type {number | null} read 폴링 타이머 */
let readPollTimer = null
/** @type {number | null} heartbeat 타이머 */
let heartbeatTimer = null
/** @type {(() => void) | null} scroll 리스너 */
let readScrollListener = null
/** @type {number} read 추적 시작 시각 */
let readStartedAt = 0
/** @type {string} read 추적 중인 게시물 slug */
let readPostSlug = ''
/** @type {string} read 추적 중인 경로 */
let readPath = ''
/** @type {string} 현재 추적 경로 */
let currentPath = ''
/** @type {string} 현재 추적 게시물 slug */
let currentPostSlug = ''
/** @type {number} 현재 페이지 체류 시작 시각 */
let pageStartedAt = 0
/** @type {number} 현재 페이지 최대 스크롤 비율 */
let maxScrollRatio = 0
/**
* 탭 단위 클라이언트 세션 ID를 반환한다.
* @returns {string} 클라이언트 세션 ID
*/
const getClientSessionId = () => {
try {
const existing = sessionStorage.getItem(CLIENT_SESSION_STORAGE_KEY)
if (existing) {
return existing
}
const created = crypto.randomUUID()
sessionStorage.setItem(CLIENT_SESSION_STORAGE_KEY, created)
return created
} catch {
return `fallback-${Date.now()}`
}
}
/**
* 게시물 상세 경로에서 slug를 추출한다.
* @param {import('vue-router').RouteLocationNormalizedLoaded} route - 현재 라우트
* @returns {string} slug 또는 빈 문자열
*/
const extractPostSlugFromRoute = (route) => {
const paramSlug = String(route.params?.slug || '').trim()
if (paramSlug && String(route.path || '').startsWith('/post/')) {
return paramSlug
}
const match = String(route.path || '').match(/^\/post\/([^/?#]+)/)
return match?.[1] ? decodeURIComponent(match[1]).trim() : ''
}
/**
* 문서 스크롤 진행 비율(0~1)을 반환한다.
* @returns {number} 스크롤 비율
*/
const getDocumentScrollRatio = () => {
const scrollTop = window.scrollY || document.documentElement.scrollTop || 0
const viewport = window.innerHeight || document.documentElement.clientHeight || 0
const scrollHeight = document.documentElement.scrollHeight || 0
const maxScroll = Math.max(scrollHeight - viewport, 0)
if (maxScroll <= 0) {
return 1
}
return Math.min(scrollTop / maxScroll, 1)
}
/**
* 현재 페이지 체류시간(초)을 반환한다.
* @returns {number} 체류시간(초)
*/
const getCurrentDurationSeconds = () => {
if (!pageStartedAt) {
return 0
}
const elapsedMs = Date.now() - pageStartedAt
return Math.min(Math.floor(elapsedMs / 1000), ANALYTICS_MAX_DURATION_SECONDS)
}
/**
* 스크롤 비율을 갱신한다.
* @returns {void}
*/
const updateMaxScrollRatio = () => {
maxScrollRatio = Math.max(maxScrollRatio, getDocumentScrollRatio())
}
/**
* read·heartbeat 리스너를 해제한다.
* @returns {void}
*/
const clearPageTracking = () => {
if (readPollTimer) {
clearInterval(readPollTimer)
readPollTimer = null
}
if (heartbeatTimer) {
clearInterval(heartbeatTimer)
heartbeatTimer = null
}
if (readScrollListener) {
window.removeEventListener('scroll', readScrollListener)
readScrollListener = null
}
readPostSlug = ''
readPath = ''
readStartedAt = 0
currentPath = ''
currentPostSlug = ''
pageStartedAt = 0
maxScrollRatio = 0
}
/**
* 통계 이벤트를 서버로 전송한다.
* @param {string} endpoint - API 경로
* @param {Object} payload - 전송 본문
* @returns {void}
*/
const sendAnalyticsPayload = (endpoint, payload) => {
const url = endpoint
const body = JSON.stringify(payload)
try {
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }))
return
}
} catch {
// sendBeacon 실패 시 fetch로 대체
}
$fetch(url, {
method: 'POST',
body: JSON.parse(body),
keepalive: true
}).catch(() => {
// 통계 실패는 사용자 경험에 영향을 주지 않는다
})
}
/**
* pageview 이벤트를 전송한다.
* @param {{ path: string, postSlug?: string, read?: boolean }} payload - 전송 본문
* @returns {void}
*/
const sendPageviewEvent = (payload) => {
sendAnalyticsPayload('/api/analytics/pageview', {
path: payload.path,
postSlug: payload.postSlug || '',
read: Boolean(payload.read)
})
}
/**
* heartbeat 이벤트를 전송한다.
* @param {{ path: string, postSlug?: string, durationSeconds: number, maxScrollRatio: number }} payload - 전송 본문
* @returns {void}
*/
const sendHeartbeatEvent = (payload) => {
sendAnalyticsPayload('/api/analytics/heartbeat', {
path: payload.path,
postSlug: payload.postSlug || '',
clientSessionId: getClientSessionId(),
durationSeconds: payload.durationSeconds,
maxScrollRatio: payload.maxScrollRatio
})
}
/**
* 현재 페이지 heartbeat를 전송한다.
* @returns {void}
*/
const sendCurrentHeartbeat = () => {
if (!currentPath || !isTrackableAnalyticsPath(currentPath)) {
return
}
updateMaxScrollRatio()
sendHeartbeatEvent({
path: currentPath,
postSlug: currentPostSlug,
durationSeconds: getCurrentDurationSeconds(),
maxScrollRatio
})
}
/**
* 읽음 조건을 만족하면 read 이벤트를 한 번 전송한다.
* @returns {void}
*/
const trySendReadEvent = () => {
if (!readPostSlug || sentReadSlugs.has(readPostSlug)) {
return
}
const elapsed = Date.now() - readStartedAt
if (elapsed < READ_MIN_DURATION_MS) {
return
}
updateMaxScrollRatio()
if (maxScrollRatio < READ_MIN_SCROLL_RATIO) {
return
}
sentReadSlugs.add(readPostSlug)
sendPageviewEvent({
path: readPath,
postSlug: readPostSlug,
read: true
})
}
/**
* 게시물 상세 read 추적을 시작한다.
* @param {string} path - 경로
* @param {string} postSlug - 게시물 slug
* @returns {void}
*/
const startReadTracking = (path, postSlug) => {
readPath = path
readPostSlug = postSlug
readStartedAt = Date.now()
if (!postSlug) {
return
}
readPollTimer = window.setInterval(() => {
trySendReadEvent()
}, 2000)
}
/**
* heartbeat·스크롤 추적을 시작한다.
* @returns {void}
*/
const startHeartbeatTracking = () => {
readScrollListener = () => {
updateMaxScrollRatio()
trySendReadEvent()
}
window.addEventListener('scroll', readScrollListener, { passive: true })
heartbeatTimer = window.setInterval(() => {
sendCurrentHeartbeat()
}, HEARTBEAT_INTERVAL_MS)
}
/**
* 라우트 변경 시 pageview·heartbeat 추적을 시작한다.
* @param {import('vue-router').RouteLocationNormalizedLoaded} route - 대상 라우트
* @returns {void}
*/
const trackRouteAnalytics = (route) => {
const path = String(route.path || '')
if (!isTrackableAnalyticsPath(path)) {
clearPageTracking()
return
}
sendCurrentHeartbeat()
const postSlug = extractPostSlugFromRoute(route)
const viewKey = `view:${path}:${postSlug}`
if (!sentViewKeys.has(viewKey)) {
sentViewKeys.add(viewKey)
sendPageviewEvent({
path,
postSlug,
read: false
})
}
clearPageTracking()
currentPath = path
currentPostSlug = postSlug
pageStartedAt = Date.now()
maxScrollRatio = getDocumentScrollRatio()
startReadTracking(path, postSlug)
startHeartbeatTracking()
sendCurrentHeartbeat()
}
/**
* 공개 페이지 방문·게시물 읽음·실시간 heartbeat 클라이언트 트래커
*/
export default defineNuxtPlugin(() => {
if (!import.meta.client) {
return
}
const router = useRouter()
const handlePageExit = () => {
sendCurrentHeartbeat()
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
handlePageExit()
}
})
window.addEventListener('pagehide', handlePageExit)
router.isReady().then(() => {
trackRouteAnalytics(router.currentRoute.value)
})
router.beforeEach(() => {
sendCurrentHeartbeat()
})
router.afterEach((to) => {
trackRouteAnalytics(to)
})
})