Add unique and new player metrics (Refs #142)
CI / checks (push) Successful in 4m53s

This commit is contained in:
2026-10-10 18:47:56 -04:00
parent 7fcf1cded2
commit e3e740cea3
16 changed files with 1212 additions and 3 deletions
+87
View File
@@ -0,0 +1,87 @@
import { db } from '../db/index.js';
import type { MetricPoint, MetricsScope, PlayerMetrics } from '../../metrics.js';
/** Populate the shared periods from retained participation, independently of occupancy samples. */
export function readPlayerMetrics(
scope: MetricsScope,
since: number,
now: number,
points: MetricPoint[]
): PlayerMetrics {
const handle = db();
const nowIso = new Date(now).toISOString();
const sinceIso = new Date(since).toISOString();
const { first } = handle
.prepare('SELECT MIN(started_at) AS first FROM leaderboard_matches WHERE started_at <= ?')
.get(nowIso) as { first: string | null };
const historyStartAt = first === null ? null : Date.parse(first);
const periods = points.map((point) => {
const available =
historyStartAt !== null &&
(historyStartAt < point.endAt || (historyStartAt === now && point.endAt === now));
point.uniquePlayers = available ? 0 : null;
point.newPlayers = available ? 0 : null;
point.playerHistoryPartial = available && historyStartAt! > point.startAt;
return { unique: new Set<string>(), fresh: new Set<string>() };
});
if (historyStartAt === null) return { historyStartAt, uniquePlayers: null, newPlayers: null };
const conditions = [
'm.last_received_at >= ?',
'm.started_at <= ?',
'p.last_seen_at >= ?',
'p.first_seen_at <= ?'
];
const params = [sinceIso, nowIso, sinceIso, nowIso];
if (scope.game !== null) {
conditions.push('m.game = ?');
params.push(scope.game);
}
if (scope.map !== null) {
conditions.push('m.map_name = ?');
params.push(scope.map);
}
// The match clock and participation PK bound this scan to recent matches.
// The GUID/first-seen index looks up the durable cross-game identity without
// reclassifying a returning player after their older matches expire.
const rows = handle
.prepare(
`SELECT p.guid, p.first_seen_at AS start, p.last_seen_at AS end,
(SELECT MIN(first_seen_at) FROM leaderboard_players WHERE guid=p.guid) AS firstSeen
FROM leaderboard_matches m JOIN leaderboard_match_players p ON p.match_id=m.id
WHERE ${conditions.join(' AND ')}`
)
.iterate(...params) as Iterable<{
guid: string;
start: string;
end: string;
firstSeen: string | null;
}>;
const unique = new Set<string>();
const fresh = new Set<string>();
for (const row of rows) {
const start = Date.parse(row.start);
const end = Date.parse(row.end);
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) continue;
unique.add(row.guid);
// Scope the original sighting, never the first later visit to this game/map.
const isNew =
row.firstSeen !== null && Date.parse(row.firstSeen) === start && start >= since;
if (isNew) fresh.add(row.guid);
for (let index = 0; index < points.length; index++) {
const point = points[index];
// Internal boundaries belong to the next period. Include a sighting
// exactly at the report's inclusive right edge in its final period.
const startsBeforeEnd = start < point.endAt || (start === now && point.endAt === now);
if (point.uniquePlayers === null || end < point.startAt || !startsBeforeEnd) continue;
periods[index].unique.add(row.guid);
if (isNew && start >= point.startAt) periods[index].fresh.add(row.guid);
}
}
for (let index = 0; index < points.length; index++) {
if (points[index].uniquePlayers === null) continue;
points[index].uniquePlayers = periods[index].unique.size;
points[index].newPlayers = periods[index].fresh.size;
}
return { historyStartAt, uniquePlayers: unique.size, newPlayers: fresh.size };
}
+6
View File
@@ -1,5 +1,6 @@
import { fleetSettings } from '../settings.js';
import { db } from '../db/index.js';
import { readPlayerMetrics } from './metrics-players.js';
import { ONLINE_MAX_MS } from '../../server-freshness.js';
import { zombieMaps } from '../../map-aliases.js';
import { readAutoscalingMetrics } from './metrics-autoscaling.js';
@@ -314,9 +315,13 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
endAt: end,
partial: start !== at || end !== at + bucketMs,
capturedHours: 0,
uniquePlayers: null,
newPlayers: null,
playerHistoryPartial: false,
...emptyUtilization()
});
}
const players = readPlayerMetrics(scope, since, now, points);
const cells: HeatmapCell[] = Array.from({ length: 7 * 24 }, (_, index) => ({
weekday: Math.floor(index / 24),
@@ -517,6 +522,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
summary,
running,
matches: matchMetrics(totalMatches),
players,
autoscaling: autoscaling.summary,
sampledServerCount: sampledServers.size,
maps,