logger.ts84 lines · main
1import { 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
13export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
14
15const LEVEL_ORDER: Record<LogLevel, number> = {
16 debug: 10,
17 info: 20,
18 warn: 30,
19 error: 40,
20};
21
22export interface LoggerOptions {
23 service: string;
24 env: string;
25 level: LogLevel;
26}
27
28export 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 */
42const REDACT_DEPTH_LIMIT = 16;
43
44function 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
58export 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}