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
+6
View File
@@ -7,6 +7,7 @@ import { migrateFileBlobs } from './file-blobs.js';
import { installPerformanceIndexes } from './performance-indexes.js';
import {
installMetricsMatchRevisions,
installMetricsPlayerRevisions,
installMetricsPolicyRevisions,
installMetricsProvisioningRevisions,
installReportRevisions
@@ -85,6 +86,11 @@ export const migrations: readonly Migration[] = [
version: 15,
name: 'metrics provisioning report revisions',
run: installMetricsProvisioningRevisions
},
{
version: 16,
name: 'metrics player history and report revisions',
run: installMetricsPlayerRevisions
}
];
+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,
+21
View File
@@ -1,5 +1,26 @@
import type Database from 'better-sqlite3';
/** Player trends also change when a participation interval extends into another period. */
export function installMetricsPlayerRevisions(handle: Database.Database) {
handle.exec(`CREATE INDEX IF NOT EXISTS idx_leaderboard_players_guid_first_seen
ON leaderboard_players(guid,first_seen_at);
DROP TRIGGER IF EXISTS report_metrics_leaderboard_match_players_UPDATE;
CREATE TRIGGER report_metrics_leaderboard_match_players_UPDATE
AFTER UPDATE ON leaderboard_match_players
WHEN OLD.match_id IS NOT NEW.match_id OR OLD.guid IS NOT NEW.guid
OR OLD.first_seen_at IS NOT NEW.first_seen_at OR OLD.last_seen_at IS NOT NEW.last_seen_at
BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='metrics'; END;`);
for (const action of ['INSERT', 'UPDATE', 'DELETE']) {
const condition =
action === 'UPDATE'
? 'WHEN OLD.guid IS NOT NEW.guid OR OLD.first_seen_at IS NOT NEW.first_seen_at'
: '';
handle.exec(`CREATE TRIGGER IF NOT EXISTS report_metrics_leaderboard_players_${action}
AFTER ${action} ON leaderboard_players ${condition}
BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='metrics'; END;`);
}
}
/** Provisioning outcomes remain report inputs even after the policy is removed. */
export function installMetricsProvisioningRevisions(handle: Database.Database) {
for (const table of ['server_deployments', 'provisioning_sources'])