From 066e482f06b648463a87fa74b2f5b4230cb5e7b9 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 11 Apr 2026 17:12:27 -0400 Subject: [PATCH] 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) --- tests/soak/core/logger.ts | 59 +++++++++++++++++++++++++++++++++ tests/soak/tests/logger.test.ts | 52 +++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 tests/soak/core/logger.ts create mode 100644 tests/soak/tests/logger.test.ts diff --git a/tests/soak/core/logger.ts b/tests/soak/core/logger.ts new file mode 100644 index 0000000..21fa3d1 --- /dev/null +++ b/tests/soak/core/logger.ts @@ -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 = { + 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; +} + +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; +} diff --git a/tests/soak/tests/logger.test.ts b/tests/soak/tests/logger.test.ts new file mode 100644 index 0000000..96a492d --- /dev/null +++ b/tests/soak/tests/logger.test.ts @@ -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); + }); +});