From d87f5bc1db64811b8698eaffe9417667c0942fb2 Mon Sep 17 00:00:00 2001 From: Horoli Date: Thu, 15 Oct 2026 17:59:43 +0900 Subject: [PATCH] fix: aggregate presence samples every five minutes with seven-day retention --- README.md | 2 +- constants/analytics.ts | 2 ++ src/client/admin.ts | 2 +- src/server/analytics-report.ts | 9 ++++---- src/server/analytics-store.ts | 9 +++++--- src/server/analytics.ts | 27 +++++++++++++++++++++--- tests/analytics-sampling.test.ts | 36 ++++++++++++++++++++++++++++++++ tests/analytics.test.ts | 29 ++++++++++++++++++++++++- 8 files changed, 103 insertions(+), 13 deletions(-) create mode 100644 tests/analytics-sampling.test.ts diff --git a/README.md b/README.md index 4654aa9..7ec3991 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ node --import tsx scripts/cleanup-legacy-data.ts --database ssessesse-ki-battle node --import tsx scripts/cleanup-legacy-data.ts --database ssessesse-ki-battle --apply --backup .work/legacy-cleanup/removed-data.ejson ``` -이벤트·방문 세션·활동 표본은 90일 보관한다. 원문 UA·URL·방 코드·인증 토큰·IP·정밀 위치·오류 본문은 수집하지 않는다. 유입 도메인과 허용 캠페인 분류만 보관한다. 분석 전송 대기는 클라이언트 최대 100건·30분·6회 시도, 서버 최대 100건·30분이다. 분석 장애는 게임 진행과 결과 집계를 막지 않는다. +이벤트·방문 세션은 90일, 활동 표본과 수집 오류 이력은 7일 보관한다. 활동은 10초마다 측정하고 5분마다 최대 활성 방문자 수·측정 횟수·수집 오류 누적값을 저장한다. 정상 종료 시 남은 구간도 저장하며, 비정상 종료 시 아직 저장하지 않은 구간은 유실될 수 있다. 표본 조회 기간은 집계 종료 시각을 기준으로 한다. 서버 시작 시 기존 활동 표본의 만료일도 기록 시각 + 7일로 단축한다. 원문 UA·URL·방 코드·인증 토큰·IP·정밀 위치·오류 본문은 수집하지 않는다. 유입 도메인과 허용 캠페인 분류만 보관한다. 분석 전송 대기는 클라이언트 최대 100건·30분·6회 시도, 서버 최대 100건·30분이다. 분석 장애는 게임 진행과 결과 집계를 막지 않는다. ## 검증 diff --git a/constants/analytics.ts b/constants/analytics.ts index d239c7d..85cc788 100644 --- a/constants/analytics.ts +++ b/constants/analytics.ts @@ -3,6 +3,8 @@ export const ACTIVITY_INTERVAL_MS = 60000; export const ANALYTICS_BATCH_SIZE = 20; export const ACTIVE_WINDOW_MS = 90000; export const PRESENCE_INTERVAL_MS = 10000; +export const PRESENCE_SAVE_INTERVAL_MS = 5 * 60 * 1000; +export const PRESENCE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; export const RETENTION_MS = 90 * 24 * 60 * 60 * 1000; export const RETRY_QUEUE_LIMIT = 100; export const RETRY_MAX_AGE_MS = VISIT_SESSION_MS; diff --git a/src/client/admin.ts b/src/client/admin.ts index aad9021..be8f0e3 100644 --- a/src/client/admin.ts +++ b/src/client/admin.ts @@ -60,7 +60,7 @@ function render() { const active = section('DAU · WAU · MAU', '조회 종료일을 포함한 최근 1/7/30일의 ID 중복 제거'); table(active, ['기간', '방문자', '플레이어'], [['DAU', number(o.dau.visitors), number(o.dau.players)], ['WAU', number(o.wau.visitors), number(o.wau.players)], ['MAU', number(o.mau.visitors), number(o.mau.players)]]); const days = section('일별 추이', 'KST 날짜 · 방문자/플레이어는 일별 중복 제거'); chart(days, report.daily, 'visitors', 'day', '#326dde'); table(days, ['날짜', '방문자', '플레이어', '생성 대전', '페이지 조회'], report.daily.map((d: any) => [d.day, d.visitors, d.players, d.matches, d.pageViews])); const hours = section('시간별 추이', 'KST 시각 · 파란 막대: 순방문자'); chart(hours, report.hourly, 'visitors', 'hour', '#326dde'); table(hours, ['시간', '순방문자', '생성 대전'], report.hourly.map((d: any) => [d.hour.replace('T', ' '), d.visitors, d.matches])); - const peak = section('최대 동시 이용 표본', '10초 간격 표본 최대값 · 순간 최대값 미측정'); cards(peak, [['표본 수', report.sampledPeak.count], ['활성 방문자 추정', report.sampledPeak.active]]); + const peak = section('최대 동시 이용 표본', '최근 7일 · 10초 측정 · 5분 집계 · 집계 종료 시각 기준 · 순간 최대값 미측정'); cards(peak, [['표본 수', report.sampledPeak.count], ['활성 방문자 추정', report.sampledPeak.active]]); } else if (tab === 'acquisition') { for (const [key, label] of [['source', '유입 채널'], ['referrer', '외부 유입 도메인'], ['device', '기기'], ['browser', '브라우저']]) { table(section(label), ['분류', '방문자', '페이지 조회'], report.acquisition[key].map((g: any) => [g.name, g.visitors, g.pageViews])); diff --git a/src/server/analytics-report.ts b/src/server/analytics-report.ts index 97f2419..5e2261a 100644 --- a/src/server/analytics-report.ts +++ b/src/server/analytics-report.ts @@ -1,5 +1,5 @@ import type { Db } from 'mongodb'; -import { RETENTION_MS } from '../../constants/analytics.js'; +import { PRESENCE_RETENTION_MS, RETENTION_MS } from '../../constants/analytics.js'; import type { AnalyticsEvent, AnalyticsFilters, Cohort, Ratio, VisitSession } from '../shared/analytics-types.js'; import type { PlayerDocument } from './stats-store.js'; export const kstDay = (date: Date) => new Date(+date + 9 * 3600000).toISOString().slice(0, 10); @@ -101,13 +101,14 @@ export async function getAnalyticsReport(db: Db, filters: AnalyticsFilters, now: const counts = (rows: AnalyticsEvent[], field: 'code' | 'action' | 'screen' | 'outcome') => Object.entries(rows.reduce>((out, e) => { const k = e[field] ?? 'unknown'; out[k] = (out[k] ?? 0) + 1; return out; }, {})).map(([name, count]) => ({ name, count })); const first = players.filter(p => p.firstVisitAt && period(p.firstVisitAt) && sessions.some(s => s.playerId === p._id && qualifies(s.playerId, s.sessionId)) && allEvents.some(e => e.playerId === p._id && e.eventType === 'page_view')); const visitActivity = sessions.filter(s => qualifies(s.playerId, s.sessionId)).flatMap(s => (s.activeDays ?? []).map(day => ({ playerId: s.playerId, day }))); + const sampleStart = new Date(Math.max(+start, +now - PRESENCE_RETENTION_MS)); const samples = await db.collection('analytics_presence_samples').aggregate([ - { $match: { occurredAt: { $gte: start, $lt: end }, environment: 'production', source: 'activity_reports' } }, - { $group: { _id: null, count: { $sum: 1 }, active: { $max: '$active' } } }, { $project: { _id: 0 } }, + { $match: { occurredAt: { $gte: sampleStart, $lt: end }, environment: 'production', source: 'activity_reports' } }, + { $group: { _id: null, count: { $sum: { $ifNull: ['$sampleCount', 1] } }, active: { $max: '$active' } } }, { $project: { _id: 0 } }, ]).toArray(); const metadata = await db.collection('analytics_metadata').findOne({ _id: 'production' as any }); const quality = await db.collection('analytics_presence_samples').aggregate([ - { $match: { occurredAt: { $gte: start, $lt: end }, environment: 'production', source: 'activity_reports' } }, + { $match: { occurredAt: { $gte: sampleStart, $lt: end }, environment: 'production', source: 'activity_reports' } }, { $group: { _id: '$restartedAt', failures: { $max: '$failures' }, dropped: { $max: '$dropped' }, rateLimited: { $max: '$rateLimited' } } }, { $group: { _id: null, failures: { $sum: '$failures' }, dropped: { $sum: '$dropped' }, rateLimited: { $sum: '$rateLimited' } } }, { $project: { _id: 0 } }, ]).toArray(); diff --git a/src/server/analytics-store.ts b/src/server/analytics-store.ts index eb6f819..bdeae72 100644 --- a/src/server/analytics-store.ts +++ b/src/server/analytics-store.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; import { MongoServerError, type Db } from 'mongodb'; -import { ACTIVE_WINDOW_MS, RETENTION_MS, VISIT_SESSION_MS } from '../../constants/analytics.js'; +import { ACTIVE_WINDOW_MS, PRESENCE_RETENTION_MS, RETENTION_MS, VISIT_SESSION_MS } from '../../constants/analytics.js'; import type { AnalyticsEvent, Presence, VisitMetadata, VisitSession } from '../shared/analytics-types.js'; import type { PlayerDocument } from './stats-store.js'; import { kstDay } from './analytics-report.js'; @@ -8,7 +8,7 @@ export interface AnalyticsStore { db: Db; touch(playerId: string, now: Date, visible: boolean, metadata: VisitMetadata, metadataFinal?: boolean): Promise; insert(event: AnalyticsEvent): Promise; - sample(now: Date, presence: Presence, environment: string, quality?: { restartedAt: Date; failures: number; dropped: number; rateLimited: number }): Promise; + sample(now: Date, presence: Presence, environment: string, quality?: { restartedAt: Date; failures: number; dropped: number; rateLimited: number }, sampleCount?: number): Promise; cleanup(now: Date): Promise; } export async function createAnalyticsStore(db: Db): Promise { @@ -21,6 +21,9 @@ export async function createAnalyticsStore(db: Db): Promise { await sessions.createIndex({ sessionId: 1 }, { unique: true }); for (const field of ['eventType', 'playerId', 'sessionId']) await events.createIndex({ [field]: 1, occurredAt: 1 }); await samples.createIndex({ occurredAt: 1 }); + // Shorten existing samples' expiry too; events and visit sessions keep their retention. + await samples.updateMany({ occurredAt: { $type: 'date' }, $expr: { $gt: ['$expiresAt', { $add: ['$occurredAt', PRESENCE_RETENTION_MS] }] } }, + [{ $set: { expiresAt: { $add: ['$occurredAt', PRESENCE_RETENTION_MS] } } }]); await players.createIndex({ 'visit.expiresAt': 1 }); const cleanup = async (now: Date) => { await players.updateMany({ 'visit.expiresAt': { $lte: now } }, { $unset: { visit: '' } }); }; await cleanup(new Date()); @@ -59,6 +62,6 @@ export async function createAnalyticsStore(db: Db): Promise { await db.collection<{ _id: string; firstCollectedAt: Date }>('analytics_metadata').updateOne({ _id: event.environment }, { $min: { firstCollectedAt: event.occurredAt } }, { upsert: true }); if (event.eventType === 'page_view' && event.playerId) await players.updateOne({ _id: event.playerId }, { $min: { firstVisitAt: event.occurredAt } }); }, - async sample(now, presence, environment, quality) { await samples.insertOne({ occurredAt: now, active: presence.active, source: presence.source, environment, ...(quality ?? {}), expiresAt: new Date(+now + RETENTION_MS) }); }, + async sample(now, presence, environment, quality, sampleCount = 1) { await samples.insertOne({ occurredAt: now, active: presence.active, source: presence.source, sampleCount, environment, ...(quality ?? {}), expiresAt: new Date(+now + PRESENCE_RETENTION_MS) }); }, }; } diff --git a/src/server/analytics.ts b/src/server/analytics.ts index 339dd60..6f84be8 100644 --- a/src/server/analytics.ts +++ b/src/server/analytics.ts @@ -1,4 +1,4 @@ -import { ACTIVITY_INTERVAL_MS, ACTIVE_WINDOW_MS, CAMPAIGN_VALUES, CLIENT_ERROR_CODES, CLIENT_EVENTS, MAX_EVENTS_PER_MINUTE, PRESENCE_INTERVAL_MS, RETENTION_SWEEP_MS, RETRY_INTERVAL_MS, RETRY_MAX_AGE_MS, RETRY_QUEUE_LIMIT, SCREENS, UI_ACTIONS } from '../../constants/analytics.js'; +import { ACTIVITY_INTERVAL_MS, ACTIVE_WINDOW_MS, CAMPAIGN_VALUES, CLIENT_ERROR_CODES, CLIENT_EVENTS, MAX_EVENTS_PER_MINUTE, PRESENCE_INTERVAL_MS, PRESENCE_SAVE_INTERVAL_MS, RETENTION_SWEEP_MS, RETRY_INTERVAL_MS, RETRY_MAX_AGE_MS, RETRY_QUEUE_LIMIT, SCREENS, UI_ACTIONS } from '../../constants/analytics.js'; import { APP_VERSION } from '../../constants/gameplay.js'; import type { AnalyticsEvent, Presence, VisitMetadata, VisitSession } from '../shared/analytics-types.js'; import type { AnalyticsStore } from './analytics-store.js'; @@ -23,9 +23,12 @@ export class Analytics { queue: Job[] = []; running = false; closed = false; timer?: any; sampleTimer?: any; httpRequests = 0; lastCleanup = 0; + private sampleWindowStartedAt: number; + private pendingSample?: { at: number; presence: Presence; count: number }; windows = new Map(); quality = { restartedAt: new Date(), lastSuccessAt: null as Date | null, failures: 0, dropped: 0, rateLimited: 0, retryFailures: 0, resultSaveFailures: 0 }; constructor(private store: AnalyticsStore, private clock: Clock, private environment = 'production') { + this.sampleWindowStartedAt = clock.now(); this.quality.restartedAt = new Date(clock.now()); this.scheduleSample(); } private activityState(p: AnalyticsIdentity) { @@ -127,10 +130,23 @@ export class Analytics { source: 'activity_reports', observedAt: new Date(this.clock.now()), lastActivityAt: values.length ? new Date(Math.max(...values.map(s => s.lastAt))) : null }; } status() { return { ...this.quality, queued: this.queue.length, httpRequests: this.httpRequests, environment: this.environment, incomplete: !!(this.quality.dropped || this.quality.failures || this.quality.rateLimited || this.queue.length), now: new Date(this.clock.now()) }; } + private takeSampleWrite() { + const sample = this.pendingSample; + if (!sample) return; + this.pendingSample = undefined; + const quality = { restartedAt: this.quality.restartedAt, failures: this.quality.failures, dropped: this.quality.dropped, rateLimited: this.quality.rateLimited }; + return { at: sample.at, run: () => this.store.sample(new Date(sample.at), sample.presence, this.environment, quality, sample.count) }; + } private scheduleSample() { this.sampleTimer = this.clock.setTimeout(() => { - const at = this.clock.now(), presence = this.live(), quality = { restartedAt: this.quality.restartedAt, failures: this.quality.failures, dropped: this.quality.dropped, rateLimited: this.quality.rateLimited }; - void this.enqueue(`sample:${at}`, at, () => this.store.sample(new Date(at), presence, this.environment, quality)).catch(() => {}); + const at = this.clock.now(), presence = this.live(); + const previous = this.pendingSample; + this.pendingSample = { at, presence: { ...presence, active: Math.max(previous?.presence.active ?? 0, presence.active) }, count: (previous?.count ?? 0) + 1 }; + if (at - this.sampleWindowStartedAt >= PRESENCE_SAVE_INTERVAL_MS) { + const write = this.takeSampleWrite()!; + void this.enqueue(`sample:${write.at}`, write.at, write.run).catch(() => {}); + this.sampleWindowStartedAt = at; + } if (at - this.lastCleanup >= RETENTION_SWEEP_MS) { this.lastCleanup = at; void this.enqueue(`cleanup:${at}`, at, () => this.store.cleanup(new Date(at))).catch(() => {}); } @@ -143,6 +159,11 @@ export class Analytics { this.closed = true; this.clock.clearTimeout(this.timer); this.clock.clearTimeout(this.sampleTimer); for (const j of this.queue) j.waiters.forEach(w => w.reject()); while (this.running) await new Promise(r => setTimeout(r, 5)); + const write = this.takeSampleWrite(); + if (write) { + try { await write.run(); } + catch { this.quality.failures++; this.quality.dropped++; } + } this.queue = []; this.states.clear(); } } diff --git a/tests/analytics-sampling.test.ts b/tests/analytics-sampling.test.ts new file mode 100644 index 0000000..b2f801b --- /dev/null +++ b/tests/analytics-sampling.test.ts @@ -0,0 +1,36 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import type { AnalyticsStore } from '../src/server/analytics-store.js'; +import { Analytics } from '../src/server/analytics.js'; +import { ManualClock } from './helpers.js'; + +test('ten-second observations preserve a brief peak in one five-minute write and flush the final partial interval', async () => { + const clock = new ManualClock(); + const writes: Parameters[] = []; + const store = { + sample: async (...args: Parameters) => { writes.push(args); }, + cleanup: async () => {}, + } as unknown as AnalyticsStore; + const analytics = new Analytics(store, clock); + try { + analytics.states.set('visitor', { visible: true, lastAt: clock.now(), metadata: { device: 'pc', browser: 'Other', source: 'direct_or_unknown', invitation: false } }); + await clock.advance(290000); + assert.equal(writes.length, 0); + assert.equal(analytics.live().active, 0); + await clock.advance(10000); + assert.equal(writes.length, 1); + assert.equal(writes[0][1].active, 1); + assert.equal(writes[0][4], 30); + await clock.advance(300000); + assert.equal(writes.length, 2); + assert.equal(writes[1][1].active, 0); + assert.equal(writes[1][4], 30); + await clock.advance(20000); + assert.equal(writes.length, 2); + await analytics.close(); + assert.equal(writes.length, 3); + assert.equal(writes[2][4], 2); + await analytics.close(); + assert.equal(writes.length, 3); + } finally { await analytics.close(); } +}); diff --git a/tests/analytics.test.ts b/tests/analytics.test.ts index 0da291b..a1b5a1d 100644 --- a/tests/analytics.test.ts +++ b/tests/analytics.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; import { MongoClient } from 'mongodb'; import { createAnalyticsStore } from '../src/server/analytics-store.js'; -import { kstDay, ratio, retention } from '../src/server/analytics-report.js'; +import { getAnalyticsReport, kstDay, ratio, retention } from '../src/server/analytics-report.js'; import { createStatsStore } from '../src/server/stats-store.js'; import { RETENTION_MS, VISIT_SESSION_MS } from '../constants/analytics.js'; @@ -19,6 +19,33 @@ test('KST boundaries, zero denominators, calendar cohorts', () => { }); +test('presence retention shortens legacy expiry and reports count raw observations alongside aggregates', { skip: !process.env.TEST_MONGODB_URI }, async () => { + const stats = await createStatsStore({ mongoUri: process.env.TEST_MONGODB_URI!, databaseName: `dragon_ball_test_samples_${randomUUID().replaceAll('-', '')}` }); + try { + const now = new Date(), at = new Date(+now - 60000), old = new Date(+now - 8 * 86400000); + const samples = stats.db.collection('analytics_presence_samples'); + await samples.insertMany([ + { occurredAt: at, active: 2, environment: 'production', source: 'activity_reports', expiresAt: new Date(+at + RETENTION_MS) }, + { occurredAt: old, active: 99, environment: 'production', source: 'activity_reports', expiresAt: new Date(+old + RETENTION_MS) }, + ]); + const store = await createAnalyticsStore(stats.db); + assert.equal(+(await samples.findOne({ occurredAt: at }))!.expiresAt, +at + 604800000); + await store.sample(at, { active: 4, connected: null, waitingRooms: null, playingPeople: null, matches: null, source: 'activity_reports' }, 'production', + { restartedAt: new Date(+at - 300000), failures: 2, dropped: 1, rateLimited: 3 }, 30); + const aggregate = await samples.findOne({ sampleCount: 30 }); + assert.equal(+aggregate!.expiresAt, +at + 604800000); + const guest = await stats.createGuest(); + await store.insert({ _id: 'old-visit', playerId: guest.playerId, occurredAt: old, eventType: 'page_view', environment: 'production', schemaVersion: 1, appVersion: '0.1.0' }); + const report = await getAnalyticsReport(stats.db, { start: new Date(+now - 30 * 86400000), end: now }, now); + assert.deepEqual(report.sampledPeak, { count: 31, active: 4 }); + assert.deepEqual(report.historicalCollection, { failures: 2, dropped: 1, rateLimited: 3 }); + assert.equal(report.overview.mau.visitors, 1); + assert.equal(+(await stats.db.collection('analytics_events').findOne({ _id: 'old-visit' as any }))!.expiresAt, +old + RETENTION_MS); + await createAnalyticsStore(stats.db); + assert.equal(+(await samples.findOne({ sampleCount: 30 }))!.expiresAt, +at + 604800000); + } finally { await stats.db.dropDatabase(); await stats.close(); } +}); + test('atomic visit session rotation, duplicate collection and safe metadata on actual MongoDB', { skip: !process.env.TEST_MONGODB_URI }, async () => { const databaseName = `dragon_ball_test_analytics_${randomUUID().replaceAll('-', '')}`; const stats = await createStatsStore({ mongoUri: process.env.TEST_MONGODB_URI!, databaseName });