358 lines
8.5 KiB
JavaScript
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);
|
|
}
|
|
}
|