통계 콘텐츠 세션 원장 추가

This commit is contained in:
2026-07-29 14:48:14 +09:00
parent 1a5a1542b7
commit 4e7e12d8fe
15 changed files with 481 additions and 47 deletions

View File

@@ -5,6 +5,7 @@ import {
ANALYTICS_ENGAGED_MIN_SECONDS,
ANALYTICS_RETENTION_PURGE_INTERVAL_MS,
ANALYTICS_VISITOR_HASH_RETENTION_DAYS,
createContentSessionHash,
clampAnalyticsDurationSeconds,
clampAnalyticsScrollRatio,
createDailyVisitorHash,
@@ -39,6 +40,15 @@ const getAnalyticsHashSecret = () => {
).trim()
}
/**
* 비어 있지 않은 통계 세션 ID인지 확인한다.
* @param {string} value - 세션 ID 후보
* @returns {boolean} 사용 가능 여부
*/
const hasAnalyticsSessionId = (value) => {
return Boolean(String(value || '').trim())
}
/**
* 통계 집계 시작일을 반환한다.
* @returns {string} YYYY-MM-DD
@@ -368,7 +378,7 @@ const recordPostAnalyticsTraffic = async (sql, day, postId, visitorHash, traffic
/**
* 페이지뷰·읽음 이벤트를 기록한다.
* @param {{ visitorHash: string, postId?: string | null, pageId?: string | null, traffic?: { sourceGroup: string, sourceName: string, deviceType: string, osName: string, keyword?: string }, recordSite?: boolean, recordView?: boolean, recordRead?: boolean }} input - 기록 입력
* @param {{ visitorHash: string, sessionHash?: string, contentSessionHash?: string, path?: string, postId?: string | null, pageId?: string | null, traffic?: { sourceGroup: string, sourceName: string, deviceType: string, osName: string, keyword?: string }, recordSite?: boolean, recordView?: boolean, recordRead?: boolean }} input - 기록 입력
* @returns {Promise<{ ok: true }>}
*/
export const recordAnalyticsPageview = async (input) => {
@@ -380,14 +390,41 @@ export const recordAnalyticsPageview = async (input) => {
const day = getAnalyticsDayKey()
const visitorHash = input.visitorHash
const sessionHash = input.sessionHash || ''
const contentSessionHash = input.contentSessionHash || ''
const path = input.path || ''
const postId = input.postId || null
const pageId = input.pageId || null
const recordSite = input.recordSite !== false
const recordView = Boolean(input.recordView)
const recordRead = Boolean(input.recordRead)
const traffic = input.traffic || null
const shouldRecordPageview = recordView || recordSite
? await markContentSessionPageview(sql, {
day,
visitorHash,
sessionHash,
contentSessionHash,
path,
postId,
pageId,
traffic
})
: true
const shouldRecordRead = recordRead
? await markContentSessionRead(sql, {
day,
visitorHash,
sessionHash,
contentSessionHash,
path,
postId,
pageId,
traffic
})
: false
if (recordSite) {
if (recordSite && shouldRecordPageview) {
await ensureSiteDailyRow(sql, day)
const isNewSiteVisitor = await registerSiteVisitor(sql, day, visitorHash)
@@ -406,7 +443,7 @@ export const recordAnalyticsPageview = async (input) => {
}
if (!postId) {
if (pageId && recordView) {
if (pageId && recordView && shouldRecordPageview) {
await ensurePageDailyRow(sql, day, pageId)
const isNewPageVisitor = await registerPageVisitor(sql, day, pageId, visitorHash)
@@ -427,7 +464,7 @@ export const recordAnalyticsPageview = async (input) => {
await ensurePostDailyRow(sql, day, postId)
if (recordView) {
if (recordView && shouldRecordPageview) {
const isNewPostVisitor = await registerPostVisitor(sql, day, postId, visitorHash)
await sql`
@@ -444,7 +481,7 @@ export const recordAnalyticsPageview = async (input) => {
}
}
if (recordRead) {
if (recordRead && shouldRecordRead) {
await sql`
UPDATE post_analytics_daily
SET reads = reads + 1
@@ -490,6 +527,20 @@ export const createSessionHashFromEvent = (event, clientSessionId) => {
})
}
/**
* 콘텐츠 단위 세션 해시를 만든다.
* @param {import('h3').H3Event} event - H3 이벤트
* @param {string} contentSessionId - 콘텐츠 단위 세션 ID
* @returns {string} content session hash
*/
export const createContentSessionHashFromEvent = (event, contentSessionId) => {
return createContentSessionHash({
contentSessionId: String(contentSessionId || '').trim(),
visitorHash: createVisitorHashFromEvent(event),
secret: getAnalyticsHashSecret()
})
}
/**
* 만료된 실시간 세션 행을 정리한다.
* @param {import('postgres').Sql} sql - DB 클라이언트
@@ -522,9 +573,136 @@ const purgeAnalyticsRetention = async (sql) => {
WHERE day < ${visitorHashCutoffDay}::date
`
await sql`
DELETE FROM analytics_content_sessions
WHERE day < ${visitorHashCutoffDay}::date
`
lastAnalyticsRetentionPurgeAt = now
}
/**
* 콘텐츠 세션에 pageview 처리를 표시한다.
* @param {import('postgres').Sql} sql - DB 클라이언트
* @param {{ day: string, contentSessionHash: string, visitorHash: string, sessionHash: string, path: string, postId?: string | null, pageId?: string | null, traffic?: { sourceGroup: string, sourceName: string, deviceType: string, osName: string, keyword?: string } | null }} input - 콘텐츠 세션 입력
* @returns {Promise<boolean>} 새 pageview 집계 필요 여부
*/
const markContentSessionPageview = async (sql, input) => {
if (!hasAnalyticsSessionId(input.contentSessionHash)) {
return true
}
const traffic = input.traffic || {}
const rows = await sql`
INSERT INTO analytics_content_sessions (
content_session_hash,
day,
visitor_hash,
session_hash,
path,
post_id,
page_id,
source_group,
source_name,
device_type,
os_name,
keyword,
view_recorded
)
VALUES (
${input.contentSessionHash},
${input.day},
${input.visitorHash},
${input.sessionHash},
${input.path},
${input.postId || null},
${input.pageId || null},
${traffic.sourceGroup || ''},
${traffic.sourceName || ''},
${traffic.deviceType || ''},
${traffic.osName || ''},
${traffic.keyword || ''},
true
)
ON CONFLICT (content_session_hash) DO UPDATE
SET
day = EXCLUDED.day,
visitor_hash = EXCLUDED.visitor_hash,
session_hash = EXCLUDED.session_hash,
path = EXCLUDED.path,
post_id = EXCLUDED.post_id,
page_id = EXCLUDED.page_id,
source_group = EXCLUDED.source_group,
source_name = EXCLUDED.source_name,
device_type = EXCLUDED.device_type,
os_name = EXCLUDED.os_name,
keyword = EXCLUDED.keyword,
view_recorded = true,
last_seen_at = now()
WHERE analytics_content_sessions.view_recorded = false
RETURNING content_session_hash
`
return rows.length > 0
}
/**
* 콘텐츠 세션에 read 처리를 표시한다.
* @param {import('postgres').Sql} sql - DB 클라이언트
* @param {{ day: string, contentSessionHash: string, visitorHash: string, sessionHash: string, path: string, postId?: string | null, pageId?: string | null, traffic?: { sourceGroup: string, sourceName: string, deviceType: string, osName: string, keyword?: string } | null }} input - 콘텐츠 세션 입력
* @returns {Promise<boolean>} 새 read 집계 필요 여부
*/
const markContentSessionRead = async (sql, input) => {
if (!hasAnalyticsSessionId(input.contentSessionHash)) {
return true
}
const traffic = input.traffic || {}
const rows = await sql`
INSERT INTO analytics_content_sessions (
content_session_hash,
day,
visitor_hash,
session_hash,
path,
post_id,
page_id,
source_group,
source_name,
device_type,
os_name,
keyword,
read_recorded,
read_at
)
VALUES (
${input.contentSessionHash},
${input.day},
${input.visitorHash},
${input.sessionHash},
${input.path},
${input.postId || null},
${input.pageId || null},
${traffic.sourceGroup || ''},
${traffic.sourceName || ''},
${traffic.deviceType || ''},
${traffic.osName || ''},
${traffic.keyword || ''},
true,
now()
)
ON CONFLICT (content_session_hash) DO UPDATE
SET
read_recorded = true,
read_at = COALESCE(analytics_content_sessions.read_at, now()),
last_seen_at = now()
WHERE analytics_content_sessions.read_recorded = false
RETURNING content_session_hash
`
return rows.length > 0
}
/**
* 날짜 문자열이 통계 조회 형식인지 확인한다.
* @param {string} value - 날짜 문자열
@@ -789,7 +967,7 @@ const incrementPageScrollBuckets = async (sql, day, pageId, columns) => {
/**
* heartbeat·체류·스크롤·실시간 세션을 기록한다.
* @param {{ event: import('h3').H3Event, sessionHash: string, path: string, postId?: string | null, postSlug?: string, pageId?: string | null, pageSlug?: string, durationSeconds: number, maxScrollRatio: number }} input - 기록 입력
* @param {{ event: import('h3').H3Event, sessionHash: string, contentSessionHash?: string, path: string, postId?: string | null, postSlug?: string, pageId?: string | null, pageSlug?: string, durationSeconds: number, maxScrollRatio: number }} input - 기록 입력
* @returns {Promise<{ ok: true }>}
*/
export const recordAnalyticsHeartbeat = async (input) => {
@@ -801,6 +979,7 @@ export const recordAnalyticsHeartbeat = async (input) => {
const day = getAnalyticsDayKey()
const sessionHash = input.sessionHash
const contentSessionHash = input.contentSessionHash || ''
const path = input.path
const postId = input.postId || null
const postSlug = input.postSlug || ''
@@ -808,25 +987,88 @@ export const recordAnalyticsHeartbeat = async (input) => {
const pageSlug = input.pageSlug || ''
const durationSeconds = clampAnalyticsDurationSeconds(input.durationSeconds)
const maxScrollRatio = clampAnalyticsScrollRatio(input.maxScrollRatio)
const visitorHash = createVisitorHashFromEvent(input.event)
const memberSession = getMemberSession(input.event)
const userId = memberSession?.userId || null
const ipAddress = getClientIp(input.event)
const previousRows = await sql`
SELECT duration_seconds, max_scroll_ratio
FROM analytics_active_sessions
WHERE session_hash = ${sessionHash}
LIMIT 1
`
const previousRows = contentSessionHash
? await sql`
SELECT duration_seconds, max_scroll_ratio, engaged_recorded
FROM analytics_content_sessions
WHERE content_session_hash = ${contentSessionHash}
LIMIT 1
`
: await sql`
SELECT duration_seconds, max_scroll_ratio, false AS engaged_recorded
FROM analytics_active_sessions
WHERE session_hash = ${sessionHash}
LIMIT 1
`
const previousDuration = Number(previousRows[0]?.duration_seconds || 0)
const previousScrollRatio = Number(previousRows[0]?.max_scroll_ratio || 0)
const wasEngaged = Boolean(previousRows[0]?.engaged_recorded)
const durationDelta = Math.max(0, durationSeconds - previousDuration)
const scrollBuckets = getNewScrollBucketColumns(previousScrollRatio, maxScrollRatio)
if (contentSessionHash) {
await sql`
INSERT INTO analytics_content_sessions (
content_session_hash,
day,
visitor_hash,
session_hash,
path,
post_id,
page_id,
duration_seconds,
max_scroll_ratio,
engaged_recorded,
scroll_25_recorded,
scroll_50_recorded,
scroll_75_recorded,
scroll_100_recorded
)
VALUES (
${contentSessionHash},
${day},
${visitorHash},
${sessionHash},
${path},
${postId},
${pageId},
${durationSeconds},
${maxScrollRatio},
${durationSeconds >= ANALYTICS_ENGAGED_MIN_SECONDS},
${maxScrollRatio >= 0.25},
${maxScrollRatio >= 0.5},
${maxScrollRatio >= 0.75},
${maxScrollRatio >= 1}
)
ON CONFLICT (content_session_hash) DO UPDATE
SET
day = EXCLUDED.day,
visitor_hash = EXCLUDED.visitor_hash,
session_hash = EXCLUDED.session_hash,
path = EXCLUDED.path,
post_id = EXCLUDED.post_id,
page_id = EXCLUDED.page_id,
duration_seconds = GREATEST(analytics_content_sessions.duration_seconds, EXCLUDED.duration_seconds),
max_scroll_ratio = GREATEST(analytics_content_sessions.max_scroll_ratio, EXCLUDED.max_scroll_ratio),
engaged_recorded = analytics_content_sessions.engaged_recorded OR EXCLUDED.engaged_recorded,
scroll_25_recorded = analytics_content_sessions.scroll_25_recorded OR EXCLUDED.scroll_25_recorded,
scroll_50_recorded = analytics_content_sessions.scroll_50_recorded OR EXCLUDED.scroll_50_recorded,
scroll_75_recorded = analytics_content_sessions.scroll_75_recorded OR EXCLUDED.scroll_75_recorded,
scroll_100_recorded = analytics_content_sessions.scroll_100_recorded OR EXCLUDED.scroll_100_recorded,
last_seen_at = now()
`
}
await sql`
INSERT INTO analytics_active_sessions (
session_hash,
content_session_hash,
user_id,
path,
post_id,
@@ -839,6 +1081,7 @@ export const recordAnalyticsHeartbeat = async (input) => {
)
VALUES (
${sessionHash},
${contentSessionHash},
${userId},
${path},
${postId},
@@ -852,14 +1095,23 @@ export const recordAnalyticsHeartbeat = async (input) => {
ON CONFLICT (session_hash) DO UPDATE
SET
user_id = COALESCE(EXCLUDED.user_id, analytics_active_sessions.user_id),
content_session_hash = EXCLUDED.content_session_hash,
path = EXCLUDED.path,
post_id = EXCLUDED.post_id,
post_slug = EXCLUDED.post_slug,
page_id = EXCLUDED.page_id,
page_slug = EXCLUDED.page_slug,
ip_address = EXCLUDED.ip_address,
duration_seconds = GREATEST(analytics_active_sessions.duration_seconds, EXCLUDED.duration_seconds),
max_scroll_ratio = GREATEST(analytics_active_sessions.max_scroll_ratio, EXCLUDED.max_scroll_ratio),
duration_seconds = CASE
WHEN analytics_active_sessions.content_session_hash = EXCLUDED.content_session_hash
THEN GREATEST(analytics_active_sessions.duration_seconds, EXCLUDED.duration_seconds)
ELSE EXCLUDED.duration_seconds
END,
max_scroll_ratio = CASE
WHEN analytics_active_sessions.content_session_hash = EXCLUDED.content_session_hash
THEN GREATEST(analytics_active_sessions.max_scroll_ratio, EXCLUDED.max_scroll_ratio)
ELSE EXCLUDED.max_scroll_ratio
END,
last_seen_at = now()
`
@@ -872,7 +1124,9 @@ export const recordAnalyticsHeartbeat = async (input) => {
WHERE day = ${day}
`
const wasSiteEngaged = previousDuration >= ANALYTICS_ENGAGED_MIN_SECONDS
const wasSiteEngaged = contentSessionHash
? wasEngaged
: previousDuration >= ANALYTICS_ENGAGED_MIN_SECONDS
const isSiteEngaged = durationSeconds >= ANALYTICS_ENGAGED_MIN_SECONDS
if (!wasSiteEngaged && isSiteEngaged) {
@@ -895,7 +1149,9 @@ export const recordAnalyticsHeartbeat = async (input) => {
AND post_id = ${postId}
`
const wasPostEngaged = previousDuration >= ANALYTICS_ENGAGED_MIN_SECONDS
const wasPostEngaged = contentSessionHash
? wasEngaged
: previousDuration >= ANALYTICS_ENGAGED_MIN_SECONDS
const isPostEngaged = durationSeconds >= ANALYTICS_ENGAGED_MIN_SECONDS
if (!wasPostEngaged && isPostEngaged) {
@@ -922,7 +1178,9 @@ export const recordAnalyticsHeartbeat = async (input) => {
AND page_id = ${pageId}
`
const wasPageEngaged = previousDuration >= ANALYTICS_ENGAGED_MIN_SECONDS
const wasPageEngaged = contentSessionHash
? wasEngaged
: previousDuration >= ANALYTICS_ENGAGED_MIN_SECONDS
const isPageEngaged = durationSeconds >= ANALYTICS_ENGAGED_MIN_SECONDS
if (!wasPageEngaged && isPageEngaged) {

View File

@@ -7,6 +7,7 @@ import {
} from '../../lib/analytics.js'
import { getPageBySlug, getPostBySlug } from '../repositories/content-repository.js'
import {
createContentSessionHashFromEvent,
createSessionHashFromEvent,
recordAnalyticsHeartbeat
} from '../repositories/analytics-repository.js'
@@ -18,6 +19,7 @@ const heartbeatInputSchema = z.object({
postSlug: z.string().trim().max(200).optional().default(''),
pageSlug: z.string().trim().max(200).optional().default(''),
clientSessionId: z.string().trim().min(8).max(120),
contentSessionId: z.string().trim().max(120).optional().default(''),
durationSeconds: z.number().int().min(0).max(1800),
maxScrollRatio: z.number().min(0).max(1)
})
@@ -78,10 +80,14 @@ export const handleAnalyticsHeartbeat = async (event) => {
}
const sessionHash = createSessionHashFromEvent(event, body.clientSessionId)
const contentSessionHash = body.contentSessionId
? createContentSessionHashFromEvent(event, body.contentSessionId)
: ''
await recordAnalyticsHeartbeat({
event,
sessionHash,
contentSessionHash,
path: body.path,
postId,
postSlug,

View File

@@ -11,6 +11,8 @@ import {
} from '../../lib/analytics-traffic.js'
import { getPageBySlug, getPostBySlug } from '../repositories/content-repository.js'
import {
createContentSessionHashFromEvent,
createSessionHashFromEvent,
createVisitorHashFromEvent,
recordAnalyticsPageview
} from '../repositories/analytics-repository.js'
@@ -21,6 +23,8 @@ const pageviewInputSchema = z.object({
path: z.string().trim().min(1).max(500),
postSlug: z.string().trim().max(200).optional().default(''),
pageSlug: z.string().trim().max(200).optional().default(''),
clientSessionId: z.string().trim().max(120).optional().default(''),
contentSessionId: z.string().trim().max(120).optional().default(''),
referrer: z.string().trim().max(1000).optional().default(''),
currentUrl: z.string().trim().max(1000).optional().default(''),
read: z.boolean().optional().default(false)
@@ -82,6 +86,12 @@ export const handleAnalyticsPageview = async (event) => {
}
const visitorHash = createVisitorHashFromEvent(event)
const sessionHash = body.clientSessionId
? createSessionHashFromEvent(event, body.clientSessionId)
: ''
const contentSessionHash = body.contentSessionId
? createContentSessionHashFromEvent(event, body.contentSessionId)
: ''
const isReadEvent = Boolean(body.read)
const traffic = {
...classifyAnalyticsTrafficSource({
@@ -93,6 +103,9 @@ export const handleAnalyticsPageview = async (event) => {
await recordAnalyticsPageview({
visitorHash,
sessionHash,
contentSessionHash,
path: body.path,
postId,
pageId,
traffic,