logger.ts84 lines · main
| 1 | import { redactValue } from './redaction.js'; |
| 2 | |
| 3 | /** |
| 4 | * Structured JSON logger shared across briven services. |
| 5 | * |
| 6 | * Emits one JSON line per call to stdout (info/debug) or stderr (warn/error). |
| 7 | * Every string in the message and the `fields` object is run through the |
| 8 | * redaction pass before serialisation, so emails and IPv4 addresses can't |
| 9 | * accidentally end up in Loki — even if a caller forgets to redact at the |
| 10 | * call site (CLAUDE.md §5.1). |
| 11 | */ |
| 12 | |
| 13 | export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; |
| 14 | |
| 15 | const LEVEL_ORDER: Record<LogLevel, number> = { |
| 16 | debug: 10, |
| 17 | info: 20, |
| 18 | warn: 30, |
| 19 | error: 40, |
| 20 | }; |
| 21 | |
| 22 | export interface LoggerOptions { |
| 23 | service: string; |
| 24 | env: string; |
| 25 | level: LogLevel; |
| 26 | } |
| 27 | |
| 28 | export interface Logger { |
| 29 | debug: (msg: string, fields?: Record<string, unknown>) => void; |
| 30 | info: (msg: string, fields?: Record<string, unknown>) => void; |
| 31 | warn: (msg: string, fields?: Record<string, unknown>) => void; |
| 32 | error: (msg: string, fields?: Record<string, unknown>) => void; |
| 33 | } |
| 34 | |
| 35 | /** |
| 36 | * Walk a value tree and apply redaction to every string. Objects and |
| 37 | * arrays are rebuilt; scalars other than strings pass through unchanged. |
| 38 | * Bounded depth to refuse pathological input (cycles, deeply nested |
| 39 | * customer-supplied objects); past the cap we replace with a sentinel |
| 40 | * rather than infinite-recursing. |
| 41 | */ |
| 42 | const REDACT_DEPTH_LIMIT = 16; |
| 43 | |
| 44 | function redactDeep(value: unknown, depth = 0): unknown { |
| 45 | if (depth > REDACT_DEPTH_LIMIT) return '[REDACTED:depth-exceeded]'; |
| 46 | if (typeof value === 'string') return redactValue(value); |
| 47 | if (Array.isArray(value)) return value.map((v) => redactDeep(v, depth + 1)); |
| 48 | if (value && typeof value === 'object') { |
| 49 | const out: Record<string, unknown> = {}; |
| 50 | for (const [k, v] of Object.entries(value as Record<string, unknown>)) { |
| 51 | out[k] = redactDeep(v, depth + 1); |
| 52 | } |
| 53 | return out; |
| 54 | } |
| 55 | return value; |
| 56 | } |
| 57 | |
| 58 | export function createLogger(options: LoggerOptions): Logger { |
| 59 | const minLevel = LEVEL_ORDER[options.level]; |
| 60 | |
| 61 | const emit = (level: LogLevel, msg: string, fields?: Record<string, unknown>): void => { |
| 62 | if (LEVEL_ORDER[level] < minLevel) return; |
| 63 | const redactedFields = fields |
| 64 | ? (redactDeep(fields) as Record<string, unknown>) |
| 65 | : undefined; |
| 66 | const line = { |
| 67 | level, |
| 68 | msg: redactValue(msg), |
| 69 | ts: new Date().toISOString(), |
| 70 | service: options.service, |
| 71 | env: options.env, |
| 72 | ...redactedFields, |
| 73 | }; |
| 74 | const channel = level === 'error' || level === 'warn' ? process.stderr : process.stdout; |
| 75 | channel.write(`${JSON.stringify(line)}\n`); |
| 76 | }; |
| 77 | |
| 78 | return { |
| 79 | debug: (m, f) => emit('debug', m, f), |
| 80 | info: (m, f) => emit('info', m, f), |
| 81 | warn: (m, f) => emit('warn', m, f), |
| 82 | error: (m, f) => emit('error', m, f), |
| 83 | }; |
| 84 | } |