import { createHash } from "node:crypto"; import { getConfig } from "./config.js"; import { getDb } from "./db.js"; import { readVisitorCookie, isValidVisitorId } from "./visitorCookie.js"; const DEFAULT_DAILY_METRICS_COLLECTION_NAME = "daily_metrics"; const DEFAULT_DAILY_VISITOR_ACTIVITY_COLLECTION_NAME = "daily_visitor_activity"; const DEFAULT_ACTIVITY_RETENTION_DAYS = 60; const METRIC_FIELDS = [ "uniqueVisitors", "totalVisits", "totalMatchStarts", "totalMatchFinishes", "visitorsWithTwoOrMoreMatches", "donationClicks", ]; const EVENT_CONFIG = { "match-started": { metricField: "totalMatchStarts", activityField: "matchStarts", }, "match-finished": { metricField: "totalMatchFinishes", activityField: "matchFinishes", }, "donation-clicked": { metricField: "donationClicks", activityField: "donationClicks", }, }; let metricsIndexesReady; let activityIndexesReady; export async function dailyMetricsRoutes(fastify) { fastify.get("/today", async () => { return getTodayDailyMetrics(); }); fastify.post("/match-started", async (request) => { return recordDailyMetricEvent("match-started", { visitorId: readVisitorCookie(request), }); }); fastify.post("/match-finished", async (request) => { return recordDailyMetricEvent("match-finished", { visitorId: readVisitorCookie(request), }); }); fastify.post("/donation-clicked", async (request) => { return recordDailyMetricEvent("donation-clicked", { visitorId: readVisitorCookie(request), }); }); } export async function recordDailyVisit(visitorId, { now = new Date() } = {}) { const date = dayKey(now); let uniqueVisitors = 0; if (isValidVisitorId(visitorId)) { const activityCollection = await getDailyVisitorActivityCollection(); const activityId = await ensureDailyVisitorActivity( activityCollection, date, visitorId, now, ); const uniqueResult = await activityCollection.updateOne( { _id: activityId, dailyUniqueCounted: { $ne: true }, }, { $set: { dailyUniqueCounted: true, lastSeenAt: now, }, $inc: { visits: 1, }, }, ); if (uniqueResult.modifiedCount > 0) { uniqueVisitors = 1; } else { await activityCollection.updateOne( { _id: activityId }, { $set: { lastSeenAt: now, }, $inc: { visits: 1, }, }, ); } } return updateDailyMetrics(date, now, { totalVisits: 1, uniqueVisitors, }); } export async function recordDailyMetricEvent(eventType, { visitorId, now = new Date() } = {}) { const eventConfig = EVENT_CONFIG[eventType]; if (!eventConfig) { throw new Error(`Unknown daily metric event: ${eventType}`); } const date = dayKey(now); const increments = { [eventConfig.metricField]: 1, }; if (isValidVisitorId(visitorId)) { const countedSecondMatch = await recordDailyVisitorEvent( date, visitorId, eventConfig.activityField, now, ); if (countedSecondMatch) { increments.visitorsWithTwoOrMoreMatches = 1; } } return updateDailyMetrics(date, now, increments); } async function recordDailyVisitorEvent(date, visitorId, activityField, now) { const activityCollection = await getDailyVisitorActivityCollection(); const activityId = await ensureDailyVisitorActivity( activityCollection, date, visitorId, now, ); if (activityField !== "matchStarts") { await activityCollection.updateOne( { _id: activityId }, { $set: { lastSeenAt: now, }, $inc: { [activityField]: 1, }, }, ); return false; } const secondMatchResult = await activityCollection.updateOne( { _id: activityId, matchStarts: 1, }, { $set: { lastSeenAt: now, }, $inc: { matchStarts: 1, }, }, ); if (secondMatchResult.modifiedCount > 0) { return true; } await activityCollection.updateOne( { _id: activityId }, { $set: { lastSeenAt: now, }, $inc: { matchStarts: 1, }, }, ); return false; } async function ensureDailyVisitorActivity(collection, date, visitorId, now) { const visitorHash = dailyVisitorHash(date, visitorId); const activityId = `${date}:${visitorHash}`; await collection.updateOne( { _id: activityId }, { $setOnInsert: { _id: activityId, date, visitorHash, dailyUniqueCounted: false, visits: 0, matchStarts: 0, matchFinishes: 0, donationClicks: 0, firstSeenAt: now, expireAt: retentionDate(now), }, }, { upsert: true }, ); return activityId; } async function updateDailyMetrics(date, now, increments) { const collection = await getDailyMetricsCollection(); const normalizedIncrements = normalizeIncrements(increments); await collection.updateOne( { _id: date }, { $setOnInsert: { _id: date, date, createdAt: now, }, $set: { updatedAt: now, }, $inc: normalizedIncrements, }, { upsert: true }, ); const today = await collection.findOne({ _id: date }); return formatDailyMetrics(today, date); } async function getTodayDailyMetrics(now = new Date()) { const date = dayKey(now); const collection = await getDailyMetricsCollection(); const today = await collection.findOne({ _id: date }); return formatDailyMetrics(today, date); } async function getDailyMetricsCollection() { const db = await getDb(); const collection = db.collection( getConfig().MONGODB_DAILY_METRICS_COLLECTION || DEFAULT_DAILY_METRICS_COLLECTION_NAME, ); await ensureDailyMetricsIndexes(collection); return collection; } async function getDailyVisitorActivityCollection() { const db = await getDb(); const collection = db.collection( getConfig().MONGODB_DAILY_VISITOR_ACTIVITY_COLLECTION || DEFAULT_DAILY_VISITOR_ACTIVITY_COLLECTION_NAME, ); await ensureDailyVisitorActivityIndexes(collection); return collection; } async function ensureDailyMetricsIndexes(collection) { if (!metricsIndexesReady) { metricsIndexesReady = collection.createIndex({ updatedAt: -1 }); } return metricsIndexesReady; } async function ensureDailyVisitorActivityIndexes(collection) { if (!activityIndexesReady) { activityIndexesReady = Promise.all([ collection.createIndex({ date: 1 }), collection.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 }), ]); } return activityIndexesReady; } function normalizeIncrements(increments) { return Object.entries(increments).reduce((result, [field, value]) => { const numericValue = Math.max(0, Math.round(Number(value) || 0)); if (METRIC_FIELDS.includes(field) && numericValue > 0) { result[field] = numericValue; } return result; }, {}); } function formatDailyMetrics(document, date) { return { date, uniqueVisitors: metricNumber(document?.uniqueVisitors), totalVisits: metricNumber(document?.totalVisits), totalMatchStarts: metricNumber(document?.totalMatchStarts), totalMatchFinishes: metricNumber(document?.totalMatchFinishes), visitorsWithTwoOrMoreMatches: metricNumber(document?.visitorsWithTwoOrMoreMatches), donationClicks: metricNumber(document?.donationClicks), updatedAt: document?.updatedAt?.toISOString?.() ?? null, }; } function metricNumber(value) { return Math.max(0, Math.round(Number(value) || 0)); } function dailyVisitorHash(date, visitorId) { return createHash("sha256") .update(`${date}:${visitorId}`) .digest("hex") .slice(0, 32); } function retentionDate(now) { const retentionDays = getConfig().DAILY_ACTIVITY_RETENTION_DAYS || DEFAULT_ACTIVITY_RETENTION_DAYS; return new Date(now.getTime() + retentionDays * 24 * 60 * 60 * 1000); } function dayKey(date) { const appConfig = getConfig(); const timeZone = appConfig.ANALYTICS_TIME_ZONE || appConfig.DEATH_STATS_TIME_ZONE || "Asia/Seoul"; try { const parts = new Intl.DateTimeFormat("en-US", { day: "2-digit", month: "2-digit", timeZone, year: "numeric", }) .formatToParts(date) .reduce((result, part) => { result[part.type] = part.value; return result; }, {}); return `${parts.year}-${parts.month}-${parts.day}`; } catch { return date.toISOString().slice(0, 10); } }