/ lib / logger.ts
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  }