Add autoscaling outcomes to Metrics (Refs #140)

This commit is contained in:
2026-10-10 17:51:31 -04:00
parent 0bfda267ec
commit 528ed7f37a
15 changed files with 612 additions and 6 deletions
+13
View File
@@ -131,6 +131,18 @@ export interface PooledAvailability {
totalMaps: number;
}
export interface AutoscalingMetrics {
/** Retained Provisioning outcomes whose finish time is within the selected window. */
added: number;
removed: number;
failed: number;
cancelled: number;
/** Successful creates with valid request/finish timestamps; includes queue time. */
readinessSamples: number;
readinessMeanSeconds: number | null;
readinessMaxSeconds: number | null;
}
export interface MetricsData {
days: MetricsDays;
since: number;
@@ -150,6 +162,7 @@ export interface MetricsData {
/** Distinct recorded names sampled in this scope/window, including zero elapsed time. */
sampledServerCount: number;
matches: MatchMetrics;
autoscaling: AutoscalingMetrics;
maps: MapMetrics[];
points: MetricPoint[];
previous: PreviousMetrics;
+6
View File
@@ -8,6 +8,7 @@ import { installPerformanceIndexes } from './performance-indexes.js';
import {
installMetricsMatchRevisions,
installMetricsPolicyRevisions,
installMetricsProvisioningRevisions,
installReportRevisions
} from '../reports/revisions.js';
import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js';
@@ -79,6 +80,11 @@ export const migrations: readonly Migration[] = [
version: 14,
name: 'metrics match report revisions',
run: installMetricsMatchRevisions
},
{
version: 15,
name: 'metrics provisioning report revisions',
run: installMetricsProvisioningRevisions
}
];
@@ -0,0 +1,89 @@
import { db } from '../db/index.js';
import { parseServerName, zombieMaps } from '../../map-aliases.js';
import type { AutoscalingMetrics, MetricsScope } from '../../metrics.js';
/** Provisioning sources survive policy/decision deletion and server removal. */
export function readAutoscalingMetrics(scope: MetricsScope, since: number, now: number) {
// Group in SQLite to avoid transferring every operation. IN deduplicates source
// links, so one operation always counts once. Keep older groups for scope options.
const groups = db()
.prepare(
`WITH operations AS (
SELECT server, action, state, finished_at >= ? AS in_window,
ROUND((julianday(finished_at)-julianday(started_at))*86400000)/1000.0 AS seconds
FROM server_deployments
WHERE id IN (SELECT operation_id FROM provisioning_sources WHERE kind='autoscaling')
AND finished_at <= ? AND state IN ('done','warning','failed','cancelled')
), measured AS (
SELECT *, CASE WHEN in_window AND action='create'
AND state IN ('done','warning') AND seconds >= 0 THEN seconds END AS readiness
FROM operations
)
SELECT server,
SUM(in_window AND action='create' AND state IN ('done','warning')) AS added,
SUM(in_window AND action='destroy' AND state IN ('done','warning')) AS removed,
SUM(in_window AND state='failed') AS failed,
SUM(in_window AND state='cancelled') AS cancelled,
COUNT(readiness) AS readinessSamples,
COALESCE(SUM(readiness),0) AS readinessSeconds,
MAX(readiness) AS readinessMaxSeconds
FROM measured GROUP BY server`
)
.all(new Date(since).toISOString(), new Date(now).toISOString()) as (Omit<
AutoscalingMetrics,
'readinessMeanSeconds'
> & { server: string; readinessSeconds: number })[];
const summary: AutoscalingMetrics = {
added: 0,
removed: 0,
failed: 0,
cancelled: 0,
readinessSamples: 0,
readinessMeanSeconds: null,
readinessMaxSeconds: null
};
const maps = new Map<string, { game: string; map: string }>();
let readinessSeconds = 0;
for (const group of groups) {
// Autoscaling names encode the selected variant. Never use today's server
// registration or the policy scope (which may cover every map in a game).
const parsed = parseServerName(group.server);
const catalog = zombieMaps(parsed?.game ?? null);
const entry = catalog.find(
(map) =>
map.slug === parsed?.mapSlug ||
map.friendly.toLowerCase().replace(/[^a-z0-9]/g, '') === parsed?.mapSlug
);
if (parsed && entry) {
const pair = { game: parsed.game, map: entry.friendly };
maps.set(JSON.stringify(pair), pair);
}
if (scope.game !== null && parsed?.game !== scope.game) continue;
if (scope.map !== null) {
const label = scope.map.toLowerCase();
// A canonical T6 engine map can describe several variants; do not
// silently combine Town, Farm, Bus Depot and TranZit for that label.
const matches = catalog.filter(
(map) =>
map.friendly.toLowerCase() === label ||
map.slug === label ||
map.canonical === label
);
if (!entry || matches.length !== 1 || matches[0].slug !== entry.slug) continue;
}
summary.added += group.added;
summary.removed += group.removed;
summary.failed += group.failed;
summary.cancelled += group.cancelled;
summary.readinessSamples += group.readinessSamples;
readinessSeconds += group.readinessSeconds;
if (group.readinessMaxSeconds !== null)
summary.readinessMaxSeconds = Math.max(
summary.readinessMaxSeconds ?? 0,
group.readinessMaxSeconds
);
}
if (summary.readinessSamples > 0)
summary.readinessMeanSeconds = readinessSeconds / summary.readinessSamples;
return { summary, maps: [...maps.values()] };
}
+4 -1
View File
@@ -2,6 +2,7 @@ import { fleetSettings } from '../settings.js';
import { db } from '../db/index.js';
import { ONLINE_MAX_MS } from '../../server-freshness.js';
import { zombieMaps } from '../../map-aliases.js';
import { readAutoscalingMetrics } from './metrics-autoscaling.js';
import {
parseMetricsQuery,
type HeatmapCell,
@@ -207,10 +208,11 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
`SELECT DISTINCT game, map_name AS map FROM leaderboard_matches WHERE started_at <= ?`
)
.all(new Date(now).toISOString()) as { game: string; map: string }[];
const autoscaling = readAutoscalingMetrics(scope, since, now);
const compareText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
const availableMaps = [
...new Map(
[...groups, ...matchMaps].map(({ game, map }) => [
[...groups, ...matchMaps, ...autoscaling.maps].map(({ game, map }) => [
JSON.stringify([game, map]),
{ game, map }
])
@@ -514,6 +516,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
summary,
running,
matches: matchMetrics(totalMatches),
autoscaling: autoscaling.summary,
sampledServerCount: sampledServers.size,
maps,
points,
+21
View File
@@ -1,5 +1,26 @@
import type Database from 'better-sqlite3';
/** 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'])
for (const action of ['INSERT', 'UPDATE', 'DELETE']) {
const versions =
action === 'INSERT' ? ['NEW'] : action === 'DELETE' ? ['OLD'] : ['OLD', 'NEW'];
const fields = ['server', 'action', 'state', 'started_at', 'finished_at'];
const condition =
table === 'provisioning_sources'
? versions.map((row) => `${row}.kind='autoscaling'`).join(' OR ')
: `EXISTS (SELECT 1 FROM provisioning_sources WHERE operation_id=${action === 'DELETE' ? 'OLD' : 'NEW'}.id AND kind='autoscaling')${
action === 'UPDATE'
? ` AND (${fields.map((field) => `OLD.${field} IS NOT NEW.${field}`).join(' OR ')})`
: ''
}`;
handle.exec(`CREATE TRIGGER IF NOT EXISTS report_metrics_${table}_${action}
AFTER ${action} ON ${table} WHEN ${condition}
BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='metrics'; END;`);
}
}
/** Match demand includes active and interrupted matches, independent of leaderboard eligibility. */
export function installMetricsMatchRevisions(handle: Database.Database) {
for (const table of ['leaderboard_matches', 'leaderboard_match_players'])
@@ -0,0 +1,70 @@
<script lang="ts">
import type { AutoscalingMetrics } from '$lib/metrics.js';
import { metricNumber } from '$ui/metrics.js';
import { countLabel } from '$ui/plural.js';
import Card from '$ui/components/primitives/Card.svelte';
import Stat from '$ui/components/primitives/Stat.svelte';
let { metrics }: { metrics: AutoscalingMetrics } = $props();
const outcomes = $derived(metrics.added + metrics.removed + metrics.failed + metrics.cancelled);
const seconds = (value: number | null) => (value === null ? '—' : `${metricNumber(value)} s`);
</script>
<Card as="section" ariaLabelledby="metrics-autoscaling-title">
<header>
<h2 id="metrics-autoscaling-title">Autoscaling</h2>
<a href="/autoscaling">Open policies</a>
</header>
<div class="stats">
<Stat label="Lobbies added" value={metrics.added} tween={false} />
<Stat label="Lobbies removed" value={metrics.removed} tween={false} />
<Stat label="Failed operations" value={metrics.failed} tween={false} />
<Stat label="Cancelled operations" value={metrics.cancelled} tween={false} />
<Stat label="Request to ready · average" value={seconds(metrics.readinessMeanSeconds)} />
<Stat label="Request to ready · maximum" value={seconds(metrics.readinessMaxSeconds)} />
</div>
<p>
{#if outcomes === 0}
No completed, failed or cancelled autoscaling operations recorded in this period and
scope.
{:else}
Autoscaling operations finished in this period and scope. Added and removed counts
include successful operations only.
{/if}
{#if metrics.readinessSamples > 0}
Readiness uses {countLabel(metrics.readinessSamples, 'completed create')} with recorded timing,
including queue time.
{:else}
Request-to-ready time is unavailable without completed creates with recorded timing.
{/if}
</p>
</Card>
<style>
header {
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
margin-bottom: var(--space-5);
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
a {
font-size: var(--text-sm);
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--space-5);
}
p {
margin: var(--space-4) 0 0;
color: var(--text-tertiary);
font-size: var(--text-xs);
line-height: 1.6;
}
</style>
+10 -1
View File
@@ -24,6 +24,14 @@ const matches = (value: unknown) =>
) &&
(value.averageGroupSize === null || nonnegative(value.averageGroupSize)) &&
(value.soloShare === null || (nonnegative(value.soloShare) && value.soloShare <= 100));
const autoscaling = (value: unknown) =>
record(value) &&
['added', 'removed', 'failed', 'cancelled', 'readinessSamples'].every(
(key) => nonnegative(value[key]) && Number.isInteger(value[key])
) &&
['readinessMeanSeconds', 'readinessMaxSeconds'].every(
(key) => value[key] === null || nonnegative(value[key])
);
const policy = (value: unknown) =>
value === null ||
(record(value) &&
@@ -40,7 +48,8 @@ function validReport(value: unknown): value is MetricsData {
!numeric(value.since) ||
!totals(value.summary) ||
!running(value.running) ||
!matches(value.matches)
!matches(value.matches) ||
!autoscaling(value.autoscaling)
)
return false;
if (
+11
View File
@@ -11,6 +11,7 @@
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Card from '$ui/components/primitives/Card.svelte';
import MetricsSummary from '$ui/components/metrics/MetricsSummary.svelte';
import MetricsAutoscaling from '$ui/components/metrics/MetricsAutoscaling.svelte';
import MetricsHeatmap from '$ui/components/metrics/MetricsHeatmap.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte';
import Button from '$ui/components/primitives/Button.svelte';
@@ -252,6 +253,7 @@
{/if}
<p class="range">{scopeLabel} · {format.period(report.since)} – {format.period(report.now)}</p>
<MetricsSummary {report} />
<MetricsAutoscaling metrics={report.autoscaling} />
{#if captured === 0}
<Card>
<EmptyState
@@ -392,6 +394,15 @@
compare observations with the current policy; historical policy settings are not
recorded.
</p>
<p>
Autoscaling counts retained Provisioning operations whose finish time falls within this
window, including both endpoints. Successful creates and removals include operations
completed with warnings; failed and cancelled operations are counted separately. Pending
operations are excluded. Request-to-ready time measures completed creates from request
through verified readiness, including queue time, and does not establish browser
visibility or successful joins. Game and map scope use the operation’s recorded server
name, preserving map variants and history after server or policy removal.
</p>
<p>
Each periodic observation covers at most {ONLINE_MAX_MS / 1000} seconds. Longer collection
gaps are excluded. Occupancy history defaults to 31 days; saved match retention is configured