This commit is contained in:
@@ -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
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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'])
|
||||
|
||||
Reference in New Issue
Block a user