Files
root-org/src/lib/utils/logger.ts
2026-02-07 01:31:55 +02:00

208 lines
5.1 KiB
TypeScript

/**
* Centralized Logger for Root Org
*
* Works on both client and server. Outputs structured logs with:
* - Timestamp
* - Level (debug/info/warn/error)
* - Context (which module/function)
* - Structured data
*
* On the server (dev terminal), logs are colorized and always visible.
* On the client, logs go to console and can optionally trigger toasts.
*/
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export interface LogEntry {
level: LogLevel;
context: string;
message: string;
data?: unknown;
error?: unknown;
timestamp: string;
}
const LEVEL_PRIORITY: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
const LEVEL_COLORS: Record<LogLevel, string> = {
debug: '\x1b[36m', // cyan
info: '\x1b[32m', // green
warn: '\x1b[33m', // yellow
error: '\x1b[31m', // red
};
const RESET = '\x1b[0m';
const BOLD = '\x1b[1m';
const DIM = '\x1b[2m';
// Minimum level to output — debug in dev, info in prod
let minLevel: LogLevel = typeof window !== 'undefined' && window.location?.hostname === 'localhost' ? 'debug' : 'info';
function shouldLog(level: LogLevel): boolean {
return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[minLevel];
}
function isServer(): boolean {
return typeof window === 'undefined';
}
function formatError(err: unknown): string {
if (err instanceof Error) {
const stack = err.stack ? `\n${err.stack}` : '';
return `${err.name}: ${err.message}${stack}`;
}
if (typeof err === 'object' && err !== null) {
try {
return JSON.stringify(err, null, 2);
} catch {
return String(err);
}
}
return String(err);
}
function formatData(data: unknown): string {
if (data === undefined || data === null) return '';
try {
return JSON.stringify(data, null, 2);
} catch {
return String(data);
}
}
function serverLog(entry: LogEntry) {
const color = LEVEL_COLORS[entry.level];
const levelTag = `${color}${BOLD}[${entry.level.toUpperCase()}]${RESET}`;
const time = `${DIM}${entry.timestamp}${RESET}`;
const ctx = `${color}[${entry.context}]${RESET}`;
let line = `${levelTag} ${time} ${ctx} ${entry.message}`;
if (entry.data !== undefined) {
line += `\n ${DIM}data:${RESET} ${formatData(entry.data)}`;
}
if (entry.error !== undefined) {
line += `\n ${color}error:${RESET} ${formatError(entry.error)}`;
}
// Use stderr for errors/warnings so they stand out in terminal
if (entry.level === 'error') {
console.error(line);
} else if (entry.level === 'warn') {
console.warn(line);
} else {
console.log(line);
}
}
function clientLog(entry: LogEntry) {
const prefix = `[${entry.level.toUpperCase()}] [${entry.context}]`;
const args: unknown[] = [prefix, entry.message];
if (entry.data !== undefined) args.push(entry.data);
if (entry.error !== undefined) args.push(entry.error);
switch (entry.level) {
case 'error':
console.error(...args);
break;
case 'warn':
console.warn(...args);
break;
case 'debug':
console.debug(...args);
break;
default:
console.log(...args);
}
}
function log(level: LogLevel, context: string, message: string, extra?: { data?: unknown; error?: unknown }) {
if (!shouldLog(level)) return;
const entry: LogEntry = {
level,
context,
message,
data: extra?.data,
error: extra?.error,
timestamp: new Date().toISOString(),
};
if (isServer()) {
serverLog(entry);
} else {
clientLog(entry);
}
// Store in recent logs buffer for debugging
recentLogs.push(entry);
if (recentLogs.length > MAX_RECENT_LOGS) {
recentLogs.shift();
}
return entry;
}
// Ring buffer of recent logs — useful for dumping context on crash
const MAX_RECENT_LOGS = 100;
const recentLogs: LogEntry[] = [];
/**
* Create a scoped logger for a specific module/context.
*
* Usage:
* ```ts
* const log = createLogger('kanban.api');
* log.info('Loading board', { data: { boardId } });
* log.error('Failed to load board', { error: err, data: { boardId } });
* ```
*/
export function createLogger(context: string) {
return {
debug: (message: string, extra?: { data?: unknown; error?: unknown }) =>
log('debug', context, message, extra),
info: (message: string, extra?: { data?: unknown; error?: unknown }) =>
log('info', context, message, extra),
warn: (message: string, extra?: { data?: unknown; error?: unknown }) =>
log('warn', context, message, extra),
error: (message: string, extra?: { data?: unknown; error?: unknown }) =>
log('error', context, message, extra),
};
}
/** Set the minimum log level */
export function setLogLevel(level: LogLevel) {
minLevel = level;
}
/** Get recent log entries (for error reports / debugging) */
export function getRecentLogs(): LogEntry[] {
return [...recentLogs];
}
/** Clear recent logs */
export function clearRecentLogs() {
recentLogs.length = 0;
}
/**
* Format recent logs as a copyable string for bug reports.
* User can paste this to you for debugging.
*/
export function dumpLogs(): string {
return recentLogs
.map((e) => {
let line = `[${e.level.toUpperCase()}] ${e.timestamp} [${e.context}] ${e.message}`;
if (e.data !== undefined) line += ` | data: ${formatData(e.data)}`;
if (e.error !== undefined) line += ` | error: ${formatError(e.error)}`;
return line;
})
.join('\n');
}