v1.3.4: 통계 확장(체류·스크롤·실시간 접속자)
- 031 마이그레이션: 체류·스크롤 집계, analytics_active_sessions - heartbeat API, 관리자 realtime API, 클라이언트 heartbeat - 대시보드: 현재 접속자 목록(로그인 닉네임·아바타), 참여 지표
This commit is contained in:
@@ -1,4 +1,10 @@
|
||||
import { isTrackableAnalyticsPath } from '../lib/analytics.js'
|
||||
import {
|
||||
ANALYTICS_MAX_DURATION_SECONDS,
|
||||
isTrackableAnalyticsPath
|
||||
} from '../lib/analytics.js'
|
||||
|
||||
/** @type {string} 탭 단위 클라이언트 세션 storage 키 */
|
||||
const CLIENT_SESSION_STORAGE_KEY = 'sori_analytics_client_session'
|
||||
|
||||
/** @type {number} 읽음 판정 최소 체류 시간(ms) */
|
||||
const READ_MIN_DURATION_MS = 15000
|
||||
@@ -6,6 +12,9 @@ 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()
|
||||
|
||||
@@ -15,6 +24,9 @@ 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
|
||||
|
||||
@@ -27,6 +39,37 @@ 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 - 현재 라우트
|
||||
@@ -60,15 +103,41 @@ const getDocumentScrollRatio = () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* read 추적 리스너를 해제한다.
|
||||
* 현재 페이지 체류시간(초)을 반환한다.
|
||||
* @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 clearReadTracking = () => {
|
||||
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
|
||||
@@ -77,20 +146,21 @@ const clearReadTracking = () => {
|
||||
readPostSlug = ''
|
||||
readPath = ''
|
||||
readStartedAt = 0
|
||||
currentPath = ''
|
||||
currentPostSlug = ''
|
||||
pageStartedAt = 0
|
||||
maxScrollRatio = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 이벤트를 서버로 전송한다.
|
||||
* @param {{ path: string, postSlug?: string, read?: boolean }} payload - 전송 본문
|
||||
* @param {string} endpoint - API 경로
|
||||
* @param {Object} payload - 전송 본문
|
||||
* @returns {void}
|
||||
*/
|
||||
const sendAnalyticsEvent = (payload) => {
|
||||
const url = '/api/analytics/pageview'
|
||||
const body = JSON.stringify({
|
||||
path: payload.path,
|
||||
postSlug: payload.postSlug || '',
|
||||
read: Boolean(payload.read)
|
||||
})
|
||||
const sendAnalyticsPayload = (endpoint, payload) => {
|
||||
const url = endpoint
|
||||
const body = JSON.stringify(payload)
|
||||
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
|
||||
@@ -110,6 +180,53 @@ const sendAnalyticsEvent = (payload) => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}
|
||||
@@ -124,12 +241,14 @@ const trySendReadEvent = () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (getDocumentScrollRatio() < READ_MIN_SCROLL_RATIO) {
|
||||
updateMaxScrollRatio()
|
||||
|
||||
if (maxScrollRatio < READ_MIN_SCROLL_RATIO) {
|
||||
return
|
||||
}
|
||||
|
||||
sentReadSlugs.add(readPostSlug)
|
||||
sendAnalyticsEvent({
|
||||
sendPageviewEvent({
|
||||
path: readPath,
|
||||
postSlug: readPostSlug,
|
||||
read: true
|
||||
@@ -137,34 +256,44 @@ const trySendReadEvent = () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 상세에서 read 추적을 시작한다.
|
||||
* 게시물 상세 read 추적을 시작한다.
|
||||
* @param {string} path - 경로
|
||||
* @param {string} postSlug - 게시물 slug
|
||||
* @returns {void}
|
||||
*/
|
||||
const startReadTracking = (path, postSlug) => {
|
||||
clearReadTracking()
|
||||
readPath = path
|
||||
readPostSlug = postSlug
|
||||
readStartedAt = Date.now()
|
||||
|
||||
if (!postSlug) {
|
||||
return
|
||||
}
|
||||
|
||||
readPath = path
|
||||
readPostSlug = postSlug
|
||||
readStartedAt = Date.now()
|
||||
|
||||
readScrollListener = () => {
|
||||
trySendReadEvent()
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', readScrollListener, { passive: true })
|
||||
readPollTimer = window.setInterval(() => {
|
||||
trySendReadEvent()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 라우트 변경 시 pageview를 기록한다.
|
||||
* 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}
|
||||
*/
|
||||
@@ -172,27 +301,38 @@ const trackRouteAnalytics = (route) => {
|
||||
const path = String(route.path || '')
|
||||
|
||||
if (!isTrackableAnalyticsPath(path)) {
|
||||
clearReadTracking()
|
||||
clearPageTracking()
|
||||
return
|
||||
}
|
||||
|
||||
sendCurrentHeartbeat()
|
||||
|
||||
const postSlug = extractPostSlugFromRoute(route)
|
||||
const viewKey = `view:${path}:${postSlug}`
|
||||
|
||||
if (!sentViewKeys.has(viewKey)) {
|
||||
sentViewKeys.add(viewKey)
|
||||
sendAnalyticsEvent({
|
||||
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) {
|
||||
@@ -201,10 +341,26 @@ export default defineNuxtPlugin(() => {
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user