feat(errors): mcpd error-log ring buffer + 'mcpctl errors'
Some checks failed
CI/CD / lint (pull_request) Successful in 1m1s
CI/CD / typecheck (pull_request) Successful in 1m2s
CI/CD / test (pull_request) Successful in 1m21s
CI/CD / smoke (pull_request) Failing after 1m50s
CI/CD / build (pull_request) Successful in 5m12s
CI/CD / publish (pull_request) Has been skipped

Operators can now see recent mcpd error/fatal logs without kubectl:
- mcpd tees level>=error pino records into an in-memory ring buffer
  (src/mcpd/src/services/error-log-buffer.ts; wired via pino.multistream in
  server.ts so stdout logging is unchanged). Captures structured errors incl.
  fatal kinds like BACKEND_TOKEN_DEAD.
- GET /api/v1/logs/errors?limit=N (RBAC: 'logs' operation).
- CLI: 'mcpctl errors [-n N]' renders TIME/LEVEL/DETAIL, most-recent-first.

Buffer unit tests (6); full suite 2223 passing. Needs a deploy to go live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-06-16 23:25:55 +01:00
parent 9f4b8c9149
commit 25da5a3a22
11 changed files with 239 additions and 2 deletions

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
import { ErrorLogBuffer } from '../src/services/error-log-buffer.js';
const line = (o: Record<string, unknown>): string => JSON.stringify(o) + '\n';
describe('ErrorLogBuffer', () => {
it('keeps error/fatal records and drops info/warn', () => {
const b = new ErrorLogBuffer();
b.recordLine(line({ level: 30, time: 1, msg: 'info' }));
b.recordLine(line({ level: 40, time: 2, msg: 'warn' }));
b.recordLine(line({ level: 50, time: 3, msg: 'an error' }));
b.recordLine(line({ level: 60, time: 4, msg: 'fatal', kind: 'BACKEND_TOKEN_DEAD' }));
const recent = b.recent();
expect(recent.map((e) => e.msg)).toEqual(['fatal', 'an error']); // most-recent first
expect(recent[0]?.kind).toBe('BACKEND_TOKEN_DEAD');
});
it('flattens an err object to its message', () => {
const b = new ErrorLogBuffer();
b.recordLine(line({ level: 50, time: 1, msg: 'boom', err: { type: 'Error', message: 'OpenBao write … 403' } }));
expect(b.recent()[0]?.err).toBe('OpenBao write … 403');
});
it('ignores blank and non-JSON lines', () => {
const b = new ErrorLogBuffer();
b.recordLine('');
b.recordLine('not json at all');
b.recordLine(' ');
expect(b.recent()).toHaveLength(0);
});
it('caps at capacity (ring buffer)', () => {
const b = new ErrorLogBuffer(3);
for (let i = 1; i <= 5; i++) b.recordLine(line({ level: 50, time: i, msg: `e${i}` }));
const recent = b.recent(10);
expect(recent.map((e) => e.msg)).toEqual(['e5', 'e4', 'e3']); // oldest two evicted
});
it('respects the limit argument', () => {
const b = new ErrorLogBuffer();
for (let i = 1; i <= 10; i++) b.recordLine(line({ level: 50, time: i }));
expect(b.recent(2)).toHaveLength(2);
});
it('handles multiple records in one chunk via the stream', (ctx) => new Promise<void>((resolve) => {
const b = new ErrorLogBuffer();
const s = b.stream();
s.write(line({ level: 50, time: 1, msg: 'a' }) + line({ level: 30, time: 2 }) + line({ level: 60, time: 3, msg: 'b' }));
s.end(() => {
expect(b.recent().map((e) => e.msg)).toEqual(['b', 'a']);
resolve();
});
}));
});