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:
adlee-was-taken
2026-04-11 17:12:27 -04:00
parent 02642840da
commit 066e482f06
2 changed files with 111 additions and 0 deletions

59
tests/soak/core/logger.ts Normal file
View 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;
}

View 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);
});
});