208 lines
5.1 KiB
TypeScript
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');
|
|
}
|