feat(soak): structured JSONL logger with child contexts
Single file, no transport, writes one JSON line per call to stdout. Child loggers inherit parent meta so scenarios can bind room/game context once and forget about it. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
59
tests/soak/core/logger.ts
Normal file
59
tests/soak/core/logger.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Structured JSONL logger for the soak harness.
|
||||||
|
*
|
||||||
|
* One JSON line per call, written to stdout by default. Child loggers
|
||||||
|
* inherit parent meta so scenarios can bind room/game context once and
|
||||||
|
* every subsequent call carries it automatically.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Logger, LogLevel } from './types';
|
||||||
|
|
||||||
|
const LEVEL_ORDER: Record<LogLevel, number> = {
|
||||||
|
debug: 0,
|
||||||
|
info: 1,
|
||||||
|
warn: 2,
|
||||||
|
error: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface LoggerOptions {
|
||||||
|
runId: string;
|
||||||
|
minLevel?: LogLevel;
|
||||||
|
/** Defaults to process.stdout.write bound to stdout. Override for tests. */
|
||||||
|
write?: (line: string) => boolean;
|
||||||
|
baseMeta?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLogger(opts: LoggerOptions): Logger {
|
||||||
|
const minLevel = opts.minLevel ?? 'info';
|
||||||
|
const write = opts.write ?? ((s: string) => process.stdout.write(s));
|
||||||
|
const baseMeta = opts.baseMeta ?? {};
|
||||||
|
|
||||||
|
function emit(level: LogLevel, msg: string, meta?: object): void {
|
||||||
|
if (LEVEL_ORDER[level] < LEVEL_ORDER[minLevel]) return;
|
||||||
|
const line = JSON.stringify({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
level,
|
||||||
|
msg,
|
||||||
|
runId: opts.runId,
|
||||||
|
...baseMeta,
|
||||||
|
...(meta ?? {}),
|
||||||
|
}) + '\n';
|
||||||
|
write(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
const logger: Logger = {
|
||||||
|
debug: (msg, meta) => emit('debug', msg, meta),
|
||||||
|
info: (msg, meta) => emit('info', msg, meta),
|
||||||
|
warn: (msg, meta) => emit('warn', msg, meta),
|
||||||
|
error: (msg, meta) => emit('error', msg, meta),
|
||||||
|
child: (meta) =>
|
||||||
|
createLogger({
|
||||||
|
runId: opts.runId,
|
||||||
|
minLevel,
|
||||||
|
write,
|
||||||
|
baseMeta: { ...baseMeta, ...meta },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return logger;
|
||||||
|
}
|
||||||
52
tests/soak/tests/logger.test.ts
Normal file
52
tests/soak/tests/logger.test.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { createLogger } from '../core/logger';
|
||||||
|
|
||||||
|
describe('logger', () => {
|
||||||
|
let writes: string[];
|
||||||
|
let write: (s: string) => boolean;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
writes = [];
|
||||||
|
write = (s: string) => {
|
||||||
|
writes.push(s);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits a JSON line per call with level and msg', () => {
|
||||||
|
const log = createLogger({ runId: 'r1', write });
|
||||||
|
log.info('hello');
|
||||||
|
expect(writes).toHaveLength(1);
|
||||||
|
const parsed = JSON.parse(writes[0]);
|
||||||
|
expect(parsed.level).toBe('info');
|
||||||
|
expect(parsed.msg).toBe('hello');
|
||||||
|
expect(parsed.runId).toBe('r1');
|
||||||
|
expect(parsed.timestamp).toBeTypeOf('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges meta into the log line', () => {
|
||||||
|
const log = createLogger({ runId: 'r1', write });
|
||||||
|
log.warn('slow', { turnMs: 3000 });
|
||||||
|
const parsed = JSON.parse(writes[0]);
|
||||||
|
expect(parsed.turnMs).toBe(3000);
|
||||||
|
expect(parsed.level).toBe('warn');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('child logger inherits parent meta', () => {
|
||||||
|
const log = createLogger({ runId: 'r1', write });
|
||||||
|
const roomLog = log.child({ room: 'room-1' });
|
||||||
|
roomLog.info('game_start');
|
||||||
|
const parsed = JSON.parse(writes[0]);
|
||||||
|
expect(parsed.room).toBe('room-1');
|
||||||
|
expect(parsed.runId).toBe('r1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects minimum level', () => {
|
||||||
|
const log = createLogger({ runId: 'r1', write, minLevel: 'warn' });
|
||||||
|
log.debug('nope');
|
||||||
|
log.info('nope');
|
||||||
|
log.warn('yes');
|
||||||
|
log.error('yes');
|
||||||
|
expect(writes).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user