Files
meshcore-analyzer/test-perf-anomaly.js
T
Kpa-clawbot a26a412c9b feat(perf): 5-min rolling-baseline anomaly detection for Write Sources (#1120) (#1593)
## Summary

Addresses the remaining acceptance gap on #1120: a true **5-minute
rolling-baseline anomaly detector** for the Perf-page Write Sources
table. The endpoints + ingestor wiring + UI scaffolding landed in #1123
(partial); this PR replaces the ad-hoc tx-rate comparison with the
rolling baseline the issue actually asks for, and adds a JS unit test
that proves the ⚠️ flag fires at 11× baseline.

## What changed

- **`public/perf.js`** — new pure helper `detectPerfAnomalies(history,
current, opts)`. Computes per-component current rate and rolling
baseline rate over a window (default 5 min). Flags components whose
current rate > 10× baseline. Includes a 0.05/s floor so a stale `0`
baseline doesn't false-positive at startup.
- **UI** — Write Sources table now shows `Rate/s`, `Baseline/s`, and
`Anomaly` columns. Operators can sanity-check the ⚠️ rather than
trusting opaque output. History is kept on `window` and pruned to a
6-min sliding ring.
- **`test-perf-anomaly.js`** — new VM-sandbox test asserting:
  - ⚠️ fires when one component runs at 11× its 5-min baseline
  - No ⚠️ at 5× (under threshold)
  - No ⚠️ until ≥30s of history has accumulated

## TDD evidence (red → green)

- Red commit `590f04d3`: introduces the stub `detectPerfAnomalies`
(returns empty `{flags:{}}`) + the test. Test FAILS on the
`assert(r.flags.backfill_path_json === true, ...)` assertion — not a
build error.

  ```
   ⚠️ fires when backfill rate hits 11× the 5-minute baseline:
     expected backfill_path_json flagged at 11× baseline, got flags={}
  2 passed, 1 failed
  ```

- Green commit `726a5e78`: implements the rolling-baseline detector. All
3 tests pass; existing `test-packet-filter.js` (79 tests) still green;
`cmd/server` Go tests for `/api/perf/*` still green.

## What is NOT in this PR (deferred / out of scope per brief)

- **SQLite-stats subsection** (WAL size + cache hit rate + pending
checkpoint) — `/api/perf/sqlite` already exists (landed in #1123). Issue
body lists it as a metric category, brief explicitly marks it OPTIONAL.
Not regressed; no changes needed.
- **Ingestor `/proc/self/io` bridge** — already lives in the ingestor
stats file (`ProcIO` field, `internal/perfio`) and is rendered on the
Perf page. No change.
- **Issue #1340** (SQLite write-lock instrumentation) — separate PR in
flight, not piggybacked.
- **No new metrics backend** (no Prometheus, no OpenTelemetry). Pure
JSON over `/api/perf/*`.

## Hard-rule compliance

- Files changed: 2 (`public/perf.js`, `test-perf-anomaly.js`) — well
inside the 3-files-outside-allowed-set cap.
- `Stats` struct unchanged.
- All colors via CSS variables — no hex literals introduced (grep
clean).
- TDD: red commit fails on assertion, green commit passes — visible in
branch history.
- PII preflight: clean on both commits.

Partial fix language deliberately not used — this completes the issue's
UI acceptance criterion. Leaving `Fixes #1120` off so the user can
verify on the staging deploy before closing.

---------

Co-authored-by: meshcore-bot <bot@meshcore>
2026-06-06 20:43:58 -07:00

108 lines
4.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Unit tests for perf.js anomaly detection — 5-minute rolling baseline.
*
* Issue #1120 acceptance criterion: "Per-component write rate > 10× steady-state
* baseline" flagged with ⚠️. The baseline must be a 5-minute rolling window,
* not a single sample-to-sample comparison (which gives false negatives during
* a slow ramp and false positives during natural bursts).
*
* This file exercises window.detectPerfAnomalies(history, current, opts).
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const code = fs.readFileSync('public/perf.js', 'utf8');
const ctx = {
window: {},
document: { addEventListener() {}, getElementById() { return null; }, hidden: true },
console,
fetch: () => Promise.resolve({ json: () => Promise.resolve(null) }),
setInterval: () => 0,
clearInterval: () => {},
registerPage: () => {},
};
vm.createContext(ctx);
vm.runInContext(code, ctx);
const detect = ctx.window.detectPerfAnomalies;
if (typeof detect !== 'function') {
console.log('FAIL: window.detectPerfAnomalies is not a function (got ' + typeof detect + ')');
process.exit(1);
}
let pass = 0, fail = 0;
function test(name, fn) {
try { fn(); pass++; console.log(' ✅ ' + name); }
catch (e) { fail++; console.log(' ❌ ' + name + ': ' + e.message); }
}
function assert(cond, msg) { if (!cond) throw new Error(msg || 'assertion failed'); }
// Build a 5-minute history where backfill_path_json increments at a steady
// 1/sec baseline (300 samples over 300s), tx_inserted at 5/sec.
function buildHistory(startMs, durSec, perSec) {
const h = [];
let cum = {};
for (const k of Object.keys(perSec)) cum[k] = 0;
for (let i = 0; i <= durSec; i++) {
const ts = new Date(startMs + i * 1000).toISOString();
const snap = { sampleAt: ts, sources: {} };
for (const k of Object.keys(perSec)) {
cum[k] += perSec[k];
snap.sources[k] = cum[k];
}
h.push(snap);
}
return h;
}
test('⚠️ fires when backfill rate hits 11× the 5-minute baseline', () => {
const t0 = Date.UTC(2026, 5, 5, 0, 0, 0);
const history = buildHistory(t0, 300, { backfill_path_json: 1, tx_inserted: 5 });
// Now a fresh sample at t0+301s where backfill_path_json jumped from 300→311
// (11/sec over 1s), tx_inserted continues at 5/sec.
const last = history[history.length - 1];
const current = {
sampleAt: new Date(t0 + 301 * 1000).toISOString(),
sources: {
backfill_path_json: last.sources.backfill_path_json + 11,
tx_inserted: last.sources.tx_inserted + 5,
},
};
const r = detect(history, current, { windowMs: 5 * 60 * 1000, factor: 10 });
assert(r && r.flags, 'expected result with flags map');
assert(r.flags.backfill_path_json === true,
'expected backfill_path_json flagged at 11× baseline, got flags=' + JSON.stringify(r.flags) +
' rates=' + JSON.stringify(r.rates) + ' baselines=' + JSON.stringify(r.baselineRates));
});
test('no flag at 5× baseline (under threshold)', () => {
const t0 = Date.UTC(2026, 5, 5, 0, 0, 0);
const history = buildHistory(t0, 300, { backfill_path_json: 2, tx_inserted: 5 });
const last = history[history.length - 1];
const current = {
sampleAt: new Date(t0 + 301 * 1000).toISOString(),
sources: {
backfill_path_json: last.sources.backfill_path_json + 10, // 10/sec vs 2/sec baseline = 5×
tx_inserted: last.sources.tx_inserted + 5,
},
};
const r = detect(history, current, { windowMs: 5 * 60 * 1000, factor: 10 });
assert(!r.flags.backfill_path_json,
'expected no flag at 5× baseline, got ' + JSON.stringify(r.flags));
});
test('no flag without enough history (< 30s of samples)', () => {
const t0 = Date.UTC(2026, 5, 5, 0, 0, 0);
const history = buildHistory(t0, 5, { backfill_path_json: 1 });
const last = history[history.length - 1];
const current = {
sampleAt: new Date(t0 + 6 * 1000).toISOString(),
sources: { backfill_path_json: last.sources.backfill_path_json + 100 },
};
const r = detect(history, current, { windowMs: 5 * 60 * 1000, factor: 10, minHistorySec: 30 });
assert(!r.flags.backfill_path_json, 'expected no flag with insufficient history');
});
console.log('\n' + pass + ' passed, ' + fail + ' failed');
process.exit(fail === 0 ? 0 : 1);