Make metrics reflect autoscaling policies (Refs #137)
CI / checks (push) Successful in 4m49s

This commit is contained in:
2026-10-10 14:48:52 -04:00
parent bd34c3c9e7
commit 33da4482a6
24 changed files with 1264 additions and 370 deletions
+79 -62
View File
@@ -1,113 +1,130 @@
import type { MapMetrics } from './metrics.js';
import { metricDuration, metricPercent } from './ui/metrics.js';
/** Product hypotheses for advisory experiments; these are not demand forecasts. */
export const HOSTING_RULES = {
pressureExpansion: 0.2,
occupancyExpansion: 0.8,
idleUtilization: 0.1,
quietPressure: 0.01,
minimumEvidenceHours: 1
/** Advisory thresholds for reviewing a policy, not demand forecasts or scaling commands. */
export const POLICY_SIGNAL_RULES = {
noEmptyShare: 0.02,
minimumEvidenceHours: 1,
minimumKnownShare: 0.8
} as const;
export interface MetricRecommendation {
game: string;
map: string;
kind: 'expand' | 'consolidate' | 'capacity' | 'watch';
kind: 'maximum' | 'spares' | 'policy' | 'reporting' | 'watch';
title: string;
reason: string;
evidence: string;
outcome: string;
tentative: boolean;
policySummary: string | null;
href: string | null;
action: string | null;
}
/** Apply the same visible rules to all selected maps, including sparse observations. */
/** Compare captured availability with the current policy; historical settings are not recorded. */
export function metricRecommendations(maps: MapMetrics[]): MetricRecommendation[] {
return maps
.map((map) => {
const utilization = map.observed > 0 ? map.active / map.observed : null;
const pressure = map.capacityObserved > 0 ? map.full / map.capacityObserved : null;
const knownShare =
map.observed + map.unknown > 0 ? map.observed / (map.observed + map.unknown) : 0;
.map((map): MetricRecommendation => {
const availability = map.availabilityObserved + map.availabilityUnknown;
const knownShare = availability > 0 ? map.availabilityObserved / availability : 0;
const lobbyHours = map.observed + map.unknown;
const knownLobbyShare = lobbyHours > 0 ? map.observed / lobbyHours : 0;
const noEmptyShare =
map.availabilityObserved > 0 ? map.noEmpty / map.availabilityObserved : null;
const policy = map.policy;
const base = {
game: map.game,
map: map.map,
evidence:
`${metricDuration(map.active)} with players / ${metricDuration(map.observed)} known server time (${metricPercent(map.active, map.observed)}). ` +
(pressure === null
? 'Capacity unavailable.'
: `All lobbies full ${metricDuration(map.full)} / ${metricDuration(map.capacityObserved)} known map time (${metricPercent(map.full, map.capacityObserved)}).`),
`${metricDuration(map.active)} with players / ${metricDuration(map.observed)} known lobby time (${metricPercent(map.active, map.observed)}). ` +
(map.unknown > 0
? `${metricDuration(map.unknown)} of lobby time has unknown occupancy. `
: '') +
(noEmptyShare === null
? 'Lobby availability is unknown.'
: `No empty lobby reported for ${metricDuration(map.noEmpty)} / ${metricDuration(map.availabilityObserved)} known map time (${metricPercent(map.noEmpty, map.availabilityObserved)}).`) +
(policy && noEmptyShare !== null
? ` At or above the current maximum (${policy.maximum}): ${metricDuration(map.noEmptyAtMaximum)}; below it: ${metricDuration(map.noEmptyBelowMaximum)}.`
: ''),
tentative:
map.observed < HOSTING_RULES.minimumEvidenceHours ||
pressure === null ||
map.capacityObserved < HOSTING_RULES.minimumEvidenceHours ||
knownShare < 0.8
map.availabilityObserved < POLICY_SIGNAL_RULES.minimumEvidenceHours ||
knownShare < POLICY_SIGNAL_RULES.minimumKnownShare ||
knownLobbyShare < POLICY_SIGNAL_RULES.minimumKnownShare,
policySummary: policy
? `Current policy: ${policy.minimum}–${policy.maximum} lobbies, ${policy.spares} spare${policy.spares === 1 ? '' : 's'}`
: null,
href: policy ? `/autoscaling#${encodeURIComponent(policy.id)}` : null,
action: policy ? 'Review policy' : null
};
if (utilization === null)
if (noEmptyShare === null)
return {
...base,
kind: 'capacity' as const,
kind: 'reporting',
title:
map.unknown > 0
map.availabilityUnknown > 0 || map.unknown > 0
? 'Check occupancy reporting'
: 'Let observations accumulate',
reason:
map.unknown > 0
? 'Captured status is unknown. Check a server’s telemetry or RCON diagnostics before choosing a hosting change.'
: 'This map has no elapsed observations in the selected window. Wait for another status check or try a wider range.',
outcome: 'Watch for known server time in the next report.',
map.availabilityUnknown > 0 || map.unknown > 0
? 'Captured occupancy is unknown. Check a lobby’s telemetry or RCON diagnostics before choosing a policy change.'
: 'This map has no elapsed availability observations in the selected window. Wait for another status check or try a wider range.',
outcome: 'Watch for known lobby availability in the next report.',
tentative: true
};
if (pressure !== null && pressure >= HOSTING_RULES.pressureExpansion)
if (!policy)
return {
...base,
kind: 'expand' as const,
title: 'Trial one additional lobby',
reason: 'Recorded lobbies were all full for at least 20% of known capacity time. Try one extra lobby during busy hours.',
kind: 'policy',
title: 'Consider adding an autoscaling policy',
reason: 'This map has no current autoscaling policy. Set a minimum, maximum, and spare target to maintain an empty lobby as players arrive.',
outcome:
'Compare all-full time and use of the additional lobby over the next comparable period.'
'Choose initial limits from observed lobby use, then compare empty-lobby availability over a comparable period.',
href: '/autoscaling',
action: 'Add policy'
};
if (pressure === null && utilization >= HOSTING_RULES.occupancyExpansion)
if (policy.pausedReason || policy.mode === 'off')
return {
...base,
kind: 'expand' as const,
title: 'Trial an extra lobby during busy hours',
reason: 'Players were present for at least 80% of known server time. Capacity is unknown, so this experiment uses occupancy as a proxy, not evidence of saturation.',
kind: 'policy',
title: policy.pausedReason
? 'Review and resume the autoscaling policy'
: 'Review and enable the autoscaling policy',
reason: policy.pausedReason
? `The current policy is paused: ${policy.pausedReason}`
: 'The current policy is off, so it cannot maintain the spare target.',
outcome:
'Check whether players use the extra lobby, and measure capacity if the server reports it.',
tentative: true
'Review the policy and any blockers before enabling it, then observe availability before adjusting its limits.'
};
if (
utilization <= HOSTING_RULES.idleUtilization &&
(pressure === null || pressure < HOSTING_RULES.quietPressure)
)
if (noEmptyShare >= POLICY_SIGNAL_RULES.noEmptyShare) {
if (map.noEmptyAtMaximum >= map.noEmptyBelowMaximum)
return {
...base,
kind: 'maximum',
title: 'Consider raising the policy maximum',
reason: `No empty lobby was reported for at least 2% of known map time. At least half of that time had a tracked lobby count at or above the current maximum of ${policy.maximum}; review whether that limit leaves room for the spare target.`,
outcome:
'Review host resources and create time, then compare empty-lobby availability after a maximum change.'
};
return {
...base,
kind: 'consolidate' as const,
title: 'Trial less idle hosting',
reason: 'Players were present for at most 10% of known time, with less than 1% measured pressure or unavailable capacity. Review one idle lobby or a shorter hosting window; preserve access to this map.',
kind: 'spares',
title: 'Review the spare target and create time',
reason: `No empty lobby was reported for at least 2% of known map time. Most of that time was below the current maximum of ${policy.maximum}; consider raising the spare target from ${policy.spares} and check how long new lobbies take to become ready.`,
outcome:
'Watch for increased all-full time or loss of player-hours, and restore the lineup if the trial hurts availability.'
};
if (pressure === null)
return {
...base,
kind: 'capacity' as const,
title: 'Check capacity during busy hours',
reason: 'Occupancy is recorded, but player limits are unavailable. Check capacity reporting while keeping the current lineup.',
outcome:
'Look for known-capacity time and all-full periods before the next hosting trial.'
'Review creation delays and blockers, then compare empty-lobby availability after a spare-target change.'
};
}
return {
...base,
kind: 'watch' as const,
title: 'Keep the lineup and watch peaks',
reason: 'These observations do not trigger the expansion or idle-hosting rules. Keep the current lineup for another comparison period.',
outcome: 'Watch whether occupied time and measured capacity pressure change.'
kind: 'watch',
title: 'Keep the policy and watch availability',
reason: 'No-empty-lobby time is below the policy review threshold. These observations do not suggest increasing the maximum or spare target.',
outcome: 'Watch for recurring periods without a reported empty lobby.'
};
})
.sort((a, b) => {
const priority = { expand: 0, consolidate: 1, capacity: 2, watch: 3 };
const priority = { maximum: 0, spares: 1, policy: 2, reporting: 3, watch: 4 };
return (
priority[a.kind] - priority[b.kind] ||
a.map.localeCompare(b.map) ||