monitor/core/modules/Metrics/playerDrop/index.ts
2025-04-16 22:30:27 +07:00

352 lines
13 KiB
TypeScript

const modulename = 'PlayerDropMetrics';
import fsp from 'node:fs/promises';
import consoleFactory from '@lib/console';
import { PDLChangeEventType, PDLFileSchema, PDLFileType, PDLHourlyRawType, PDLHourlyType, PDLServerBootDataSchema } from './playerDropSchemas';
import { classifyDrop } from './classifyDropReason';
import { PDL_RETENTION, PDL_UNKNOWN_LIST_SIZE_LIMIT } from './config';
import { ZodError } from 'zod';
import { getDateHourEnc, parseDateHourEnc } from './playerDropUtils';
import { MultipleCounter } from '../statsUtils';
import { throttle } from 'throttle-debounce';
import { PlayerDropsDetailedWindow, PlayerDropsSummaryHour } from '@routes/playerDrops';
import { migratePlayerDropsFile } from './playerDropMigrations';
import { parseFxserverVersion } from '@lib/fxserver/fxsVersionParser';
import { PlayerDropEvent } from '@modules/FxPlayerlist';
import { txEnv } from '@core/globalData';
const console = consoleFactory(modulename);
//Consts
export const LOG_DATA_FILE_VERSION = 2;
const LOG_DATA_FILE_NAME = 'stats_playerDrop.json';
/**
* Stores player drop logs, and also logs other information that might be relevant to player crashes,
* such as changes to the detected game/server version, resources, etc.
*
* NOTE: PDL = PlayerDropLog
*/
export default class PlayerDropMetrics {
private readonly logFilePath = `${txEnv.profilePath}/data/${LOG_DATA_FILE_NAME}`;
private eventLog: PDLHourlyType[] = [];
private lastGameVersion: string | undefined;
private lastServerVersion: string | undefined;
private lastResourceList: string[] | undefined;
private lastUnknownReasons: string[] = [];
private queueSaveEventLog = throttle(
15_000,
this.saveEventLog.bind(this),
{ noLeading: true }
);
constructor() {
setImmediate(() => {
this.loadEventLog();
});
}
/**
* Get the recent category count for player drops in the last X hours
*/
public getRecentDropTally(windowHours: number) {
const logCutoff = (new Date).setUTCMinutes(0, 0, 0) - (windowHours * 60 * 60 * 1000) - 1;
const flatCounts = this.eventLog
.filter((entry) => entry.hour.dateHourTs >= logCutoff)
.map((entry) => entry.dropTypes.toSortedValuesArray())
.flat();
const cumulativeCounter = new MultipleCounter();
cumulativeCounter.merge(flatCounts);
return cumulativeCounter.toSortedValuesArray();
}
/**
* Get the recent log with drop/crash/changes for the last X hours
*/
public getRecentSummary(windowHours: number): PlayerDropsSummaryHour[] {
const logCutoff = (new Date).setUTCMinutes(0, 0, 0) - (windowHours * 60 * 60 * 1000);
const windowSummary = this.eventLog
.filter((entry) => entry.hour.dateHourTs >= logCutoff)
.map((entry) => ({
hour: entry.hour.dateHourStr,
changes: entry.changes.length,
dropTypes: entry.dropTypes.toSortedValuesArray(),
}));
return windowSummary;
}
/**
* Get the data for the player drops drilldown card within a inclusive time window
*/
public getWindowData(windowStart: number, windowEnd: number): PlayerDropsDetailedWindow {
const allChanges: PDLChangeEventType[] = [];
const crashTypes = new MultipleCounter();
const dropTypes = new MultipleCounter();
const resKicks = new MultipleCounter();
const filteredLogs = this.eventLog.filter((entry) => {
return entry.hour.dateHourTs >= windowStart && entry.hour.dateHourTs <= windowEnd;
});
for (const log of filteredLogs) {
allChanges.push(...log.changes);
crashTypes.merge(log.crashTypes);
dropTypes.merge(log.dropTypes);
resKicks.merge(log.resKicks);
}
return {
changes: allChanges,
crashTypes: crashTypes.toSortedValuesArray(true),
dropTypes: dropTypes.toSortedValuesArray(true),
resKicks: resKicks.toSortedValuesArray(true),
};
}
/**
* Returns the object of the current hour object in log.
* Creates one if doesn't exist one for the current hour.
*/
private getCurrentLogHourRef() {
const { dateHourTs, dateHourStr } = getDateHourEnc();
const currentHourLog = this.eventLog.find((entry) => entry.hour.dateHourStr === dateHourStr);
if (currentHourLog) return currentHourLog;
const newHourLog: PDLHourlyType = {
hour: {
dateHourTs: dateHourTs,
dateHourStr: dateHourStr,
},
changes: [],
crashTypes: new MultipleCounter(),
dropTypes: new MultipleCounter(),
resKicks: new MultipleCounter(),
};
this.eventLog.push(newHourLog);
return newHourLog;
}
/**
* Handles receiving the data sent to the logger as soon as the server boots
*/
public handleServerBootData(rawPayload: any) {
const logRef = this.getCurrentLogHourRef();
//Parsing data
const validation = PDLServerBootDataSchema.safeParse(rawPayload);
if (!validation.success) {
console.warn(`Invalid server boot data: ${validation.error.errors}`);
return;
}
const { gameName, gameBuild, fxsVersion, resources } = validation.data;
let shouldSave = false;
//Game version change
const gameString = `${gameName}:${gameBuild}`;
if (gameString) {
if (!this.lastGameVersion) {
shouldSave = true;
} else if (gameString !== this.lastGameVersion) {
shouldSave = true;
logRef.changes.push({
ts: Date.now(),
type: 'gameChanged',
oldVersion: this.lastGameVersion,
newVersion: gameString,
});
}
this.lastGameVersion = gameString;
}
//Server version change
let { build: serverBuild, platform: serverPlatform } = parseFxserverVersion(fxsVersion);
const fxsVersionString = `${serverPlatform}:${serverBuild}`;
if (fxsVersionString) {
if (!this.lastServerVersion) {
shouldSave = true;
} else if (fxsVersionString !== this.lastServerVersion) {
shouldSave = true;
logRef.changes.push({
ts: Date.now(),
type: 'fxsChanged',
oldVersion: this.lastServerVersion,
newVersion: fxsVersionString,
});
}
this.lastServerVersion = fxsVersionString;
}
//Resource list change - if no resources, ignore as that's impossible
if (resources.length) {
if (!this.lastResourceList || !this.lastResourceList.length) {
shouldSave = true;
} else {
const resAdded = resources.filter(r => !this.lastResourceList!.includes(r));
const resRemoved = this.lastResourceList.filter(r => !resources.includes(r));
if (resAdded.length || resRemoved.length) {
shouldSave = true;
logRef.changes.push({
ts: Date.now(),
type: 'resourcesChanged',
resAdded,
resRemoved,
});
}
}
this.lastResourceList = resources;
}
//Saving if needed
if (shouldSave) {
this.queueSaveEventLog();
}
}
/**
* Handles receiving the player drop event, and returns the category of the drop
*/
public handlePlayerDrop(event: PlayerDropEvent) {
const drop = classifyDrop(event);
//Ignore server shutdown drops
if (drop.category === false) return false;
//Log the drop
const logRef = this.getCurrentLogHourRef();
logRef.dropTypes.count(drop.category);
if (drop.category === 'resource' && drop.resource) {
logRef.resKicks.count(drop.resource);
} else if (drop.category === 'crash' && drop.cleanReason) {
logRef.crashTypes.count(drop.cleanReason);
} else if (drop.category === 'unknown' && drop.cleanReason) {
if (!this.lastUnknownReasons.includes(drop.cleanReason)) {
this.lastUnknownReasons.push(drop.cleanReason);
}
}
this.queueSaveEventLog();
return drop.category;
}
/**
* Resets the player drop stats log
*/
public resetLog(reason: string) {
if (typeof reason !== 'string' || !reason) throw new Error(`reason required`);
this.eventLog = [];
this.lastGameVersion = undefined;
this.lastServerVersion = undefined;
this.lastResourceList = undefined;
this.lastUnknownReasons = [];
this.queueSaveEventLog.cancel({ upcomingOnly: true });
this.saveEventLog(reason);
}
/**
* Loads the stats database/cache/history
*/
private async loadEventLog() {
try {
const rawFileData = await fsp.readFile(this.logFilePath, 'utf8');
const fileData = JSON.parse(rawFileData);
let statsData: PDLFileType;
if (fileData.version === LOG_DATA_FILE_VERSION) {
statsData = PDLFileSchema.parse(fileData);
} else {
try {
statsData = await migratePlayerDropsFile(fileData);
} catch (error) {
throw new Error(`Failed to migrate ${LOG_DATA_FILE_NAME} from ${fileData?.version} to ${LOG_DATA_FILE_VERSION}: ${(error as Error).message}`);
}
}
this.lastGameVersion = statsData.lastGameVersion;
this.lastServerVersion = statsData.lastServerVersion;
this.lastResourceList = statsData.lastResourceList;
this.lastUnknownReasons = statsData.lastUnknownReasons;
this.eventLog = statsData.log.map((entry): PDLHourlyType => {
return {
hour: parseDateHourEnc(entry.hour),
changes: entry.changes,
crashTypes: new MultipleCounter(entry.crashTypes),
dropTypes: new MultipleCounter(entry.dropTypes),
resKicks: new MultipleCounter(entry.resKicks),
}
});
console.verbose.ok(`Loaded ${this.eventLog.length} log entries from cache`);
this.optimizeStatsLog();
} catch (error) {
if ((error as any)?.code === 'ENOENT') {
console.verbose.debug(`${LOG_DATA_FILE_NAME} not found, starting with empty stats.`);
this.resetLog('File was just created, no data yet');
return;
}
if (error instanceof ZodError) {
console.warn(`Failed to load ${LOG_DATA_FILE_NAME} due to invalid data.`);
this.resetLog('Failed to load log file due to invalid data');
} else {
console.warn(`Failed to load ${LOG_DATA_FILE_NAME} with message: ${(error as Error).message}`);
this.resetLog('Failed to load log file due to unknown error');
}
console.warn('Since this is not a critical file, it will be reset.');
}
}
/**
* Optimizes the event log by removing old entries
*/
private optimizeStatsLog() {
if (this.lastUnknownReasons.length > PDL_UNKNOWN_LIST_SIZE_LIMIT) {
this.lastUnknownReasons = this.lastUnknownReasons.slice(-PDL_UNKNOWN_LIST_SIZE_LIMIT);
}
const maxAge = Date.now() - PDL_RETENTION;
const cutoffIdx = this.eventLog.findIndex((entry) => entry.hour.dateHourTs > maxAge);
if (cutoffIdx > 0) {
this.eventLog = this.eventLog.slice(cutoffIdx);
}
}
/**
* Saves the stats database/cache/history
*/
private async saveEventLog(emptyReason?: string) {
try {
const sizeBefore = this.eventLog.length;
this.optimizeStatsLog();
if (!this.eventLog.length) {
if (sizeBefore) {
emptyReason ??= 'Cleared due to retention policy';
}
} else {
emptyReason = undefined;
}
const savePerfData: PDLFileType = {
version: LOG_DATA_FILE_VERSION,
emptyReason,
lastGameVersion: this.lastGameVersion ?? 'unknown',
lastServerVersion: this.lastServerVersion ?? 'unknown',
lastResourceList: this.lastResourceList ?? [],
lastUnknownReasons: this.lastUnknownReasons,
log: this.eventLog.map((entry): PDLHourlyRawType => {
return {
hour: entry.hour.dateHourStr,
changes: entry.changes,
crashTypes: entry.crashTypes.toArray(),
dropTypes: entry.dropTypes.toArray(),
resKicks: entry.resKicks.toArray(),
}
}),
};
await fsp.writeFile(this.logFilePath, JSON.stringify(savePerfData));
} catch (error) {
console.warn(`Failed to save ${LOG_DATA_FILE_NAME} with message: ${(error as Error).message}`);
}
}
};