Files
sori.studio/server/utils/analytics-heartbeat-input.js
zenn abb77dbb4d v1.3.4: 통계 확장(체류·스크롤·실시간 접속자)
- 031 마이그레이션: 체류·스크롤 집계, analytics_active_sessions
- heartbeat API, 관리자 realtime API, 클라이언트 heartbeat
- 대시보드: 현재 접속자 목록(로그인 닉네임·아바타), 참여 지표
2026-05-20 12:26:39 +09:00

72 lines
1.8 KiB
JavaScript

import { z } from 'zod'
import {
isBotUserAgent,
isTrackableAnalyticsPath,
normalizePostSlugForAnalytics
} from '../../lib/analytics.js'
import { getPostBySlug } from '../repositories/content-repository.js'
import {
createSessionHashFromEvent,
recordAnalyticsHeartbeat
} from '../repositories/analytics-repository.js'
const heartbeatInputSchema = z.object({
path: z.string().trim().min(1).max(500),
postSlug: z.string().trim().max(200).optional().default(''),
clientSessionId: z.string().trim().min(8).max(120),
durationSeconds: z.number().int().min(0).max(1800),
maxScrollRatio: z.number().min(0).max(1)
})
/**
* heartbeat 추적 요청을 처리한다.
* @param {import('h3').H3Event} event - H3 이벤트
* @returns {Promise<{ ok: true }>}
*/
export const handleAnalyticsHeartbeat = async (event) => {
const parsedBody = heartbeatInputSchema.safeParse(await readBody(event))
if (!parsedBody.success) {
throw createError({
statusCode: 400,
message: '통계 heartbeat 형식이 올바르지 않습니다.'
})
}
const body = parsedBody.data
const userAgent = String(getRequestHeader(event, 'user-agent') || '')
if (isBotUserAgent(userAgent)) {
return { ok: true }
}
if (!isTrackableAnalyticsPath(body.path)) {
return { ok: true }
}
const postSlug = normalizePostSlugForAnalytics(body.postSlug)
let postId = null
if (postSlug) {
const post = await getPostBySlug(postSlug)
if (!post) {
return { ok: true }
}
postId = post.id
}
const sessionHash = createSessionHashFromEvent(event, body.clientSessionId)
await recordAnalyticsHeartbeat({
event,
sessionHash,
path: body.path,
postId,
postSlug,
durationSeconds: body.durationSeconds,
maxScrollRatio: body.maxScrollRatio
})
return { ok: true }
}