arena/server/dailyMetrics.js

358 lines
8.5 KiB
JavaScript

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);
}
}