Files
electric-cherry/src/lib/server/fleet/metrics.ts
T

356 lines
13 KiB
TypeScript

import { fleetSettings } from '../settings.js';
import { db } from '../db/index.js';
import { ONLINE_MAX_MS } from '../../server-freshness.js';
import {
parseMetricsQuery,
type HeatmapCell,
type MapMetrics,
type MetricPoint,
type MetricsData,
type MetricsDays,
type MetricsQuery,
type PreviousMetrics,
type ServerMetrics,
type Utilization
} from '../../metrics.js';
export type { MapMetrics, Utilization } from '../../metrics.js';
export interface MetricSample {
server: string;
game: string;
map: string;
players: number | null;
capacity: number | null;
}
export function recordMetrics(samples: MetricSample[], at = Date.now()): void {
const handle = db();
handle.transaction(() => {
const insert = handle.prepare(
'INSERT OR REPLACE INTO metric_samples (at, server, game, map, players, capacity) VALUES (?, ?, ?, ?, ?, ?)'
);
for (const s of samples) insert.run(at, s.server, s.game, s.map, s.players, s.capacity);
const days = fleetSettings().metricsKeepDays;
if (days)
handle.prepare('DELETE FROM metric_samples WHERE at < ?').run(at - days * 86400_000);
})();
}
function emptyUtilization(): Utilization {
return { observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 };
}
function addObservation(total: Utilization, players: number | null, hours: number): void {
if (players === null) total.unknown += hours;
else {
total.observed += hours;
if (players > 0) total.active += hours;
else if (players === 0) total.idle += hours;
total.playerHours += players * hours;
}
}
/** Each collection is a complete fleet snapshot. Hold it until the next collection,
* capped at the freshness limit; longer gaps contribute no observed or unknown time.
* Read the indexed history once and share the intervals across every aggregation. */
export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now()): MetricsData {
const query = parseMetricsQuery(
new URLSearchParams(
typeof input === 'number'
? { days: String(input) }
: {
days: String(input.days),
game: input.game ?? '',
map: input.map ?? '',
timeZone: input.timeZone
}
)
);
const { days, ...scope } = query;
const since = now - days * 86400_000;
const bucketMs = days === 1 ? 3600_000 : 86400_000;
const handle = db();
// These small grouped results keep options and retained evidence independent
// of the chosen scope/window without transferring a second raw history scan.
const groups = handle
.prepare(
`SELECT game, map, MIN(at) AS first, MAX(at) AS latest
FROM metric_samples WHERE at <= ? GROUP BY game, map ORDER BY game, map`
)
.all(now) as { game: string; map: string; first: number; latest: number }[];
const matchesScope = (sample: { game: string; map: string }) =>
(scope.game === null || sample.game === scope.game) &&
(scope.map === null || sample.map === scope.map);
const evidenceBounds = (values: typeof groups) =>
values.reduce<{ first: number | null; latest: number | null }>(
(bounds, group) => ({
first: bounds.first === null ? group.first : Math.min(bounds.first, group.first),
latest:
bounds.latest === null ? group.latest : Math.max(bounds.latest, group.latest)
}),
{ first: null, latest: null }
);
const globalBounds = evidenceBounds(groups);
const scopedBounds = evidenceBounds(groups.filter(matchesScope));
const registered = new Set(
(
handle.prepare('SELECT name FROM servers WHERE destroyed_at IS NULL').all() as {
name: string;
}[]
).map((server) => server.name)
);
const metadata = {
firstRetainedSampleAt: globalBounds.first,
latestSampleAt: globalBounds.latest,
registeredServerCount: registered.size,
scopeFirstSampleAt: scopedBounds.first,
scopeLatestSampleAt: scopedBounds.latest
};
const scopeOptions = {
games: [...new Set(groups.map((group) => group.game))],
maps: groups.map(({ game, map }) => ({ game, map }))
};
const summary = emptyUtilization();
const previous: PreviousMetrics = {
since: since - days * 86400_000,
until: since,
summary: emptyUtilization(),
firstSampleAt: null,
lastSampleAt: null,
partialHistory: false
};
const serverTotals = new Map<string, ServerMetrics>();
const mapTotals = new Map<string, Map<string, MapMetrics>>();
const points: MetricPoint[] = [];
for (let at = Math.floor(since / bucketMs) * bucketMs; at < now; at += bucketMs) {
const end = Math.min(at + bucketMs, now);
const start = Math.max(at, since);
points.push({
at,
startAt: start,
endAt: end,
partial: start !== at || end !== at + bucketMs,
...emptyUtilization()
});
}
const cells: HeatmapCell[] = Array.from({ length: 7 * 24 }, (_, index) => ({
weekday: Math.floor(index / 24),
hour: index % 24,
distinctDates: 0,
...emptyUtilization()
}));
const cellDates = cells.map(() => new Set<string>());
const calendar = new Intl.DateTimeFormat('en-US', {
timeZone: scope.timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
weekday: 'short',
hour: 'numeric',
hourCycle: 'h23'
});
const weekdays: Record<string, number> = {
Mon: 0,
Tue: 1,
Wed: 2,
Thu: 3,
Fri: 4,
Sat: 5,
Sun: 6
};
let cachedMinute = NaN;
let localCell = { index: 0, date: '' };
interface BatchCounts {
known: number;
active: number;
idle: number;
unknown: number;
players: number;
}
function addHeatmap(counts: BatchCounts, start: number, end: number): void {
// Samples are bounded by the shared freshness window. Splitting at UTC minute boundaries
// handles local hour/day boundaries, DST, and fractional-hour zones.
// Cache each minute once across the complete fleet collection batches.
for (let cursor = start; cursor < end;) {
const minute = Math.floor(cursor / 60_000);
if (minute !== cachedMinute) {
const parts = Object.fromEntries(
calendar.formatToParts(cursor).map((part) => [part.type, part.value])
);
localCell = {
index: weekdays[parts.weekday] * 24 + Number(parts.hour),
date: `${parts.year}-${parts.month}-${parts.day}`
};
cachedMinute = minute;
}
const stop = Math.min(end, (minute + 1) * 60_000);
const hours = (stop - cursor) / 3600_000;
const cell = cells[localCell.index];
cell.observed += counts.known * hours;
cell.active += counts.active * hours;
cell.idle += counts.idle * hours;
cell.unknown += counts.unknown * hours;
cell.playerHours += counts.players * hours;
cellDates[localCell.index].add(localCell.date);
cursor = stop;
}
}
type RecordedSample = MetricSample & { at: number };
let batch: RecordedSample[] = [];
function aggregateBatch(nextAt: number): void {
if (!batch.length) return;
const at = batch[0].at;
const samples = scope.game === null ? batch : batch.filter(matchesScope);
if (!samples.length) return;
const end = Math.min(nextAt, at + ONLINE_MAX_MS, now);
const priorHours =
Math.max(0, Math.min(end, previous.until) - Math.max(at, previous.since)) / 3600_000;
if (priorHours > 0) {
previous.firstSampleAt ??= at;
previous.lastSampleAt = at;
for (const sample of samples)
addObservation(previous.summary, sample.players, priorHours);
}
// Preserve current-window zero-duration rows without bringing rows from
// the separate comparison window into the visible server/map tables.
if (at < since - ONLINE_MAX_MS) return;
const start = Math.max(at, since);
const hours = Math.max(0, end - start) / 3600_000;
const pressure = new Map<MapMetrics, { known: boolean; full: boolean }>();
const counts: BatchCounts = { known: 0, active: 0, idle: 0, unknown: 0, players: 0 };
// Split once for the entire batch; observations crossing UTC hour/day
// boundaries contribute to both buckets without shifting their duration.
const slices: { point: MetricPoint; hours: number }[] = [];
for (let cursor = start; cursor < end;) {
const index = Math.floor((cursor - points[0].at) / bucketMs);
const point = points[index];
const stop = Math.min(end, point.endAt);
slices.push({ point, hours: (stop - cursor) / 3600_000 });
cursor = stop;
}
for (const sample of samples) {
let server = serverTotals.get(sample.server);
if (!server) {
server = {
server: sample.server,
currentRegistered: registered.has(sample.server),
...emptyUtilization()
};
serverTotals.set(sample.server, server);
}
let game = mapTotals.get(sample.game);
if (!game) {
game = new Map();
mapTotals.set(sample.game, game);
}
let map = game.get(sample.map);
if (!map) {
map = {
game: sample.game,
map: sample.map,
full: 0,
capacityObserved: 0,
...emptyUtilization()
};
game.set(sample.map, map);
}
addObservation(summary, sample.players, hours);
addObservation(server, sample.players, hours);
addObservation(map, sample.players, hours);
for (const slice of slices) addObservation(slice.point, sample.players, slice.hours);
if (sample.players === null) counts.unknown++;
else {
counts.known++;
counts.players += sample.players;
if (sample.players > 0) counts.active++;
else if (sample.players === 0) counts.idle++;
}
const known =
sample.players !== null && sample.capacity !== null && sample.capacity > 0;
const full = known && sample.players! >= sample.capacity!;
const previous = pressure.get(map);
pressure.set(map, {
known: known && (previous?.known ?? true),
full: full && (previous?.full ?? true)
});
}
for (const [map, state] of pressure) {
if (state.known) {
map.capacityObserved += hours;
if (state.full) map.full += hours;
}
}
addHeatmap(counts, start, end);
}
type SampleRow = [
at: number,
server: string,
game: string,
map: string,
players: number | null,
capacity: number | null
];
const samples = handle
.prepare(
`SELECT at, server, game, map, players, capacity FROM metric_samples
WHERE at >= ? AND at <= ? ORDER BY at`
)
.raw()
.iterate(previous.since - ONLINE_MAX_MS, now) as IterableIterator<SampleRow>;
for (const [at, server, game, map, players, capacity] of samples) {
if (batch.length && at !== batch[0].at) {
aggregateBatch(at);
batch = [];
}
batch.push({ at, server, game, map, players, capacity });
}
aggregateBatch(now);
const compareText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
const servers = [...serverTotals.values()].sort(
(a, b) => b.active - a.active || compareText(a.server, b.server)
);
const maps = [...mapTotals.values()]
.flatMap((game) => [...game.values()])
.sort(
(a, b) =>
b.playerHours - a.playerHours ||
compareText(a.map, b.map) ||
compareText(a.game, b.game)
);
previous.partialHistory =
scopedBounds.first === null ||
scopedBounds.first > previous.since ||
previous.firstSampleAt === null;
for (let index = 0; index < cells.length; index++)
cells[index].distinctDates = cellDates[index].size;
const fullMapHours = maps.reduce((total, map) => total + map.full, 0);
const knownMapHours = maps.reduce((total, map) => total + map.capacityObserved, 0);
const pooledPressure = {
value: knownMapHours > 0 ? (100 * fullMapHours) / knownMapHours : null,
fullMapHours,
knownMapHours,
coveredMaps: maps.filter((map) => map.capacityObserved > 0).length,
totalMaps: maps.length
};
return {
days,
since,
now,
...metadata,
scope,
scopeOptions,
summary,
servers,
maps,
points,
previous,
heatmap: { timeZone: scope.timeZone, cells },
pooledPressure
};
}