logger.ts
1 // ============================================================================ 2 // GapZero — Structured Logger 3 // Dev: pretty console output. Prod: JSON lines for log aggregation. 4 // ============================================================================ 5 6 export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; 7 8 export interface LogEntry { 9 timestamp: string; 10 level: LogLevel; 11 event: string; 12 data?: Record<string, unknown>; 13 duration_ms?: number; 14 error?: string; 15 } 16 17 const LOG_LEVEL_ORDER: Record<LogLevel, number> = { 18 debug: 0, 19 info: 1, 20 warn: 2, 21 error: 3, 22 }; 23 24 const isDev = process.env.NODE_ENV !== 'production'; 25 const minLevel: LogLevel = isDev ? 'debug' : 'info'; 26 27 function shouldLog(level: LogLevel): boolean { 28 return LOG_LEVEL_ORDER[level] >= LOG_LEVEL_ORDER[minLevel]; 29 } 30 31 function formatEntry(entry: LogEntry): string { 32 if (isDev) { 33 // Pretty format for development 34 const prefix = `[${entry.level.toUpperCase()}]`; 35 const duration = entry.duration_ms !== undefined ? ` (${entry.duration_ms}ms)` : ''; 36 const data = entry.data ? ` ${JSON.stringify(entry.data)}` : ''; 37 const error = entry.error ? ` ERROR: ${entry.error}` : ''; 38 return `${prefix} ${entry.event}${duration}${data}${error}`; 39 } 40 // JSON lines for production 41 return JSON.stringify(entry); 42 } 43 44 function log(level: LogLevel, event: string, data?: Record<string, unknown>, extra?: Partial<LogEntry>): void { 45 if (!shouldLog(level)) return; 46 47 const entry: LogEntry = { 48 timestamp: new Date().toISOString(), 49 level, 50 event, 51 ...extra, 52 ...(data && { data }), 53 }; 54 55 const formatted = formatEntry(entry); 56 57 switch (level) { 58 case 'error': 59 console.error(formatted); 60 break; 61 case 'warn': 62 console.warn(formatted); 63 break; 64 default: 65 console.log(formatted); 66 } 67 } 68 69 export const logger = { 70 debug: (event: string, data?: Record<string, unknown>) => log('debug', event, data), 71 info: (event: string, data?: Record<string, unknown>) => log('info', event, data), 72 warn: (event: string, data?: Record<string, unknown>) => log('warn', event, data), 73 error: (event: string, error?: unknown, data?: Record<string, unknown>) => { 74 const errorStr = error instanceof Error ? error.message : String(error ?? ''); 75 log('error', event, data, { error: errorStr }); 76 }, 77 timed: (event: string, durationMs: number, data?: Record<string, unknown>) => { 78 log('info', event, data, { duration_ms: durationMs }); 79 }, 80 }; 81 82 export interface TimerHandle { 83 end: (data?: Record<string, unknown>) => number; 84 } 85 86 export function startTimer(event: string): TimerHandle { 87 const start = Date.now(); 88 return { 89 end(data?: Record<string, unknown>): number { 90 const duration = Date.now() - start; 91 logger.timed(event, duration, data); 92 return duration; 93 }, 94 }; 95 }