106 lines
3.9 KiB
TypeScript
106 lines
3.9 KiB
TypeScript
import { Logger, MetricAggregate } from './Logger';
|
|
import {
|
|
PERF_TOUCH_RESPONSE_MAX_MS,
|
|
PERF_JUMP_STATE_TOGGLE_MAX_MS,
|
|
PERF_COMBO_RECOGNITION_MAX_MS,
|
|
PERF_PARABOLIC_ANGLE_ACCURACY_TARGET,
|
|
PERF_AIR_JUMP_BLOCK_RATE_TARGET,
|
|
MAX_FIRST_PACKAGE_BYTES,
|
|
MAX_AUDIO_BUNDLE_BYTES,
|
|
MAX_MEMORY_PEAK_BYTES,
|
|
} from './Constants';
|
|
|
|
/**
|
|
* Performance monitor (task 10.2, req 18 & 20).
|
|
*
|
|
* Aggregates all the KPI samples recorded through `Logger.metric(...)` and
|
|
* reports pass/fail against every threshold listed in the requirements doc.
|
|
* CI can run `collectReport()` and assert that `allPassing === true`.
|
|
*/
|
|
|
|
export interface IPerfThreshold {
|
|
metric: string;
|
|
/** Budget target (max for latency, min for rates). */
|
|
limit: number;
|
|
comparator: '<=' | '>=';
|
|
requirementId: string;
|
|
}
|
|
|
|
export const CORE_PERF_THRESHOLDS: ReadonlyArray<IPerfThreshold> = [
|
|
{ metric: 'input/touchStart', limit: PERF_TOUCH_RESPONSE_MAX_MS, comparator: '<=', requirementId: 'req 20.1' },
|
|
{ metric: 'jump/state_toggle_ms', limit: PERF_JUMP_STATE_TOGGLE_MAX_MS, comparator: '<=', requirementId: 'req 20.2' },
|
|
{ metric: 'input/parabolic_accuracy', limit: PERF_PARABOLIC_ANGLE_ACCURACY_TARGET, comparator: '>=', requirementId: 'req 20.3' },
|
|
{ metric: 'input/combo_recognition_ms', limit: PERF_COMBO_RECOGNITION_MAX_MS, comparator: '<=', requirementId: 'req 20.4' },
|
|
{ metric: 'jump/air_jump_block_rate', limit: PERF_AIR_JUMP_BLOCK_RATE_TARGET, comparator: '>=', requirementId: 'req 20.5' },
|
|
];
|
|
|
|
export interface IPerfCheckResult {
|
|
threshold: IPerfThreshold;
|
|
aggregate?: MetricAggregate;
|
|
passing: boolean;
|
|
reason: string;
|
|
}
|
|
|
|
export interface IPerfReport {
|
|
allPassing: boolean;
|
|
checks: IPerfCheckResult[];
|
|
/** Optional build/runtime sizes filled in by CI (bytes). */
|
|
firstPackageBytes?: number;
|
|
audioBundleBytes?: number;
|
|
memoryPeakBytes?: number;
|
|
/** Top-level pass/fail for the size budgets. */
|
|
sizeBudgetPassing?: boolean;
|
|
}
|
|
|
|
export class PerfMonitor {
|
|
constructor(
|
|
private readonly logger: Logger,
|
|
private readonly thresholds: ReadonlyArray<IPerfThreshold> = CORE_PERF_THRESHOLDS
|
|
) {}
|
|
|
|
public collectReport(buildSizes?: {
|
|
firstPackageBytes?: number;
|
|
audioBundleBytes?: number;
|
|
memoryPeakBytes?: number;
|
|
}): IPerfReport {
|
|
const checks: IPerfCheckResult[] = this.thresholds.map((t) => this.check(t));
|
|
let sizeBudgetPassing: boolean | undefined;
|
|
if (buildSizes) {
|
|
sizeBudgetPassing =
|
|
(buildSizes.firstPackageBytes ?? 0) <= MAX_FIRST_PACKAGE_BYTES &&
|
|
(buildSizes.audioBundleBytes ?? 0) <= MAX_AUDIO_BUNDLE_BYTES &&
|
|
(buildSizes.memoryPeakBytes ?? 0) <= MAX_MEMORY_PEAK_BYTES;
|
|
}
|
|
const allPassing = checks.every((c) => c.passing) && (sizeBudgetPassing ?? true);
|
|
return {
|
|
allPassing,
|
|
checks,
|
|
firstPackageBytes: buildSizes?.firstPackageBytes,
|
|
audioBundleBytes: buildSizes?.audioBundleBytes,
|
|
memoryPeakBytes: buildSizes?.memoryPeakBytes,
|
|
sizeBudgetPassing,
|
|
};
|
|
}
|
|
|
|
private check(t: IPerfThreshold): IPerfCheckResult {
|
|
const agg = this.logger.aggregate(t.metric);
|
|
if (!agg) {
|
|
return {
|
|
threshold: t,
|
|
passing: false,
|
|
reason: `no samples recorded for "${t.metric}"`,
|
|
};
|
|
}
|
|
// For latency, use p95. For rate metrics, use avg.
|
|
const isRate = t.comparator === '>=';
|
|
const observed = isRate ? agg.avg : agg.p95;
|
|
const passing = isRate ? observed >= t.limit : observed <= t.limit;
|
|
return {
|
|
threshold: t,
|
|
aggregate: agg,
|
|
passing,
|
|
reason: `${t.metric} ${isRate ? 'avg' : 'p95'}=${observed.toFixed(2)} vs limit ${t.limit} (${t.comparator})`,
|
|
};
|
|
}
|
|
}
|