diff --git a/server/lib/log-coalescer.js b/server/lib/log-coalescer.js index d0163ec..09aa54b 100644 --- a/server/lib/log-coalescer.js +++ b/server/lib/log-coalescer.js @@ -14,22 +14,32 @@ const buf = new Map(); let flushMs = 30000; let timer = null; -// record(key, line, {warn}) — `key` collapses repeats; `line` is the human text to emit. +// record(key, line, {warn, peak, peakUnit}) — `key` collapses repeats; `line` is the +// stable human text. #146 P3.7: `peak` (a number) tracks the MAX over the window so the +// summary carries the PEAK (the number that matters in an incident), not an arbitrary +// sample's value. function record(key, line, opts = {}) { let e = buf.get(key); if (!e) { if (buf.size >= MAX_KEYS) flush(); // bounded: never grow past MAX_KEYS - e = { count: 0, sample: line, warn: !!opts.warn }; + e = { count: 0, sample: line, warn: !!opts.warn, peak: null, peakUnit: opts.peakUnit || '' }; buf.set(key, e); } e.count += 1; - e.sample = line; // keep the most recent detail + e.sample = line; // keep the most recent stable label e.warn = e.warn || !!opts.warn; + if (opts.peak != null) { e.peak = e.peak == null ? opts.peak : Math.max(e.peak, opts.peak); if (opts.peakUnit) e.peakUnit = opts.peakUnit; } } function flush() { for (const [, e] of buf) { - const line = e.count > 1 ? `${e.sample} (x${e.count} in ${Math.round(flushMs / 1000)}s)` : e.sample; + let line; + if (e.count > 1) { + const peak = e.peak != null ? `, peak ${e.peak}${e.peakUnit}` : ''; + line = `${e.sample} (x${e.count} in ${Math.round(flushMs / 1000)}s${peak})`; + } else { + line = e.peak != null ? `${e.sample} (${e.peak}${e.peakUnit})` : e.sample; + } (e.warn ? console.warn : console.log)(line); } buf.clear(); diff --git a/server/services/loop-lag.js b/server/services/loop-lag.js index 1724ac0..258dfc1 100644 --- a/server/services/loop-lag.js +++ b/server/services/loop-lag.js @@ -83,7 +83,9 @@ function sample() { if (band !== prev) { console.log(`[loop-lag] band=${band} (was ${prev}) mean=${snap.mean_ms}ms p99=${snap.p99_ms}ms max=${snap.max_ms}ms`); } else if (band !== 'normal') { - logCoalescer.record(`loop-lag:${band}`, `[loop-lag] band=${band} p99=${snap.p99_ms}ms max=${snap.max_ms}ms`); + // #146 P3.7: coalesce repeats and carry the PEAK p99 over the window (not a random + // sample's) — the peak is the number that matters during an incident. + logCoalescer.record(`loop-lag:${band}`, `[loop-lag] band=${band}`, { peak: snap.p99_ms, peakUnit: 'ms' }); } // #143 global pressure valve — log ONLY the band edge (open/close), not per shed diff --git a/server/test/log-selfprotect.test.js b/server/test/log-selfprotect.test.js index 08cc91e..faecf93 100644 --- a/server/test/log-selfprotect.test.js +++ b/server/test/log-selfprotect.test.js @@ -32,6 +32,17 @@ test('N identical lines in a window emit ONE summarized line with the count', () assert.match(out[0], /band=critical/, 'keeps the sample text'); }); +test('P3.7: coalesced numeric line carries the PEAK over the window, not a random sample', () => { + coalescer.reset(); + coalescer.record('lag', '[loop-lag] band=critical', { peak: 300, peakUnit: 'ms' }); + coalescer.record('lag', '[loop-lag] band=critical', { peak: 1502, peakUnit: 'ms' }); + coalescer.record('lag', '[loop-lag] band=critical', { peak: 900, peakUnit: 'ms' }); + const out = capture(() => coalescer.flush()); + assert.equal(out.length, 1); + assert.match(out[0], /x3/); + assert.match(out[0], /peak 1502ms/, 'emits the MAX p99 over the window'); +}); + test('a single occurrence logs verbatim (no count suffix)', () => { coalescer.reset(); coalescer.record('k', 'one-off line');