mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-11 16:58:50 +00:00
a26a412c9b
## 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>
108 lines
4.2 KiB
JavaScript
108 lines
4.2 KiB
JavaScript
/* 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);
|