fix: aggregate presence samples every five minutes with seven-day retention

This commit is contained in:
2026-10-15 17:59:43 +09:00
parent 1a1f29e397
commit d87f5bc1db
8 changed files with 103 additions and 13 deletions
+1 -1
View File
@@ -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]));
+5 -4
View File
@@ -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<Record<string, number>>((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();
+6 -3
View File
@@ -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<VisitSession>;
insert(event: AnalyticsEvent): Promise<void>;
sample(now: Date, presence: Presence, environment: string, quality?: { restartedAt: Date; failures: number; dropped: number; rateLimited: number }): Promise<void>;
sample(now: Date, presence: Presence, environment: string, quality?: { restartedAt: Date; failures: number; dropped: number; rateLimited: number }, sampleCount?: number): Promise<void>;
cleanup(now: Date): Promise<void>;
}
export async function createAnalyticsStore(db: Db): Promise<AnalyticsStore> {
@@ -21,6 +21,9 @@ export async function createAnalyticsStore(db: Db): Promise<AnalyticsStore> {
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<AnalyticsStore> {
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) }); },
};
}
+24 -3
View File
@@ -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<string, { at: number; count: number }>();
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();
}
}