Files
ChatLab/packages/node-runtime/src/logging/app-logger.ts
T
diguaandClaude Sonnet 4.6 2dbe569f2a fix(logging): fix four review issues in unified logger
- Front-end error reporter was posting to /_web/_web/logs/report (double
  base URL prefix); fixed to /logs/report
- uncaughtException/unhandledRejection handlers in CLI and Electron did not
  exit after logging, overriding Node's default crash behavior; added
  process.exit(1)
- NodePathProvider constructor in run() and chatlab status triggered
  migrateFromElectronIfNeeded() as a side effect; added getSystemLogsDir()
  to get the system logs path without resolving user data
- formatData() was called outside write()'s try/catch, so errors with
  circular cause objects could escape appLogger.error(); moved inside

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:28:09 +08:00

128 lines
4.1 KiB
TypeScript

/**
* Unified application logger (platform-agnostic, Node side).
*
* Shared by Electron main process, CLI and CLI Web runtime. Writes general
* application/diagnostic logs to a single rolling file `<logsDir>/app.log`.
*
* Scope of this logger: general app + key-path + crash logs. AI logs (AiLogger)
* and import perf logs keep their own per-scenario files.
*/
import * as fs from 'fs'
import * as path from 'path'
import { extractErrorInfo, extractErrorStack } from '../ai/ai-logger'
type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'
const LEVEL_ORDER: Record<LogLevel, number> = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 }
// Rotate when app.log reaches this size; old content moves to app.old.log.
const MAX_SIZE_BYTES = 10 * 1024 * 1024 // 10MB
class AppLogger {
private logFile: string
private oldLogFile: string
private threshold: LogLevel
constructor(logsDir: string) {
this.logFile = path.join(logsDir, 'app.log')
this.oldLogFile = path.join(logsDir, 'app.old.log')
this.threshold = resolveThreshold()
}
debug(scope: string, message: string, data?: unknown): void {
this.write('DEBUG', scope, message, data)
}
info(scope: string, message: string, data?: unknown): void {
this.write('INFO', scope, message, data)
}
warn(scope: string, message: string, data?: unknown): void {
this.write('WARN', scope, message, data)
}
/** `data` may be an Error; its name/message/stack are extracted automatically. */
error(scope: string, message: string, data?: unknown): void {
this.write('ERROR', scope, message, data)
}
getLogPath(): string {
return this.logFile
}
private write(level: LogLevel, scope: string, message: string, data?: unknown): void {
if (LEVEL_ORDER[level] < LEVEL_ORDER[this.threshold]) return
let line = `[${new Date().toISOString()}] [${level}] [${scope}] ${message}`
try {
const tail = formatData(data)
if (tail) line += `\n${tail}`
line += '\n'
ensureDir(path.dirname(this.logFile))
this.rotateIfNeeded()
fs.appendFileSync(this.logFile, line, 'utf-8')
} catch (err) {
// Logging must never break the app; surface to console only.
console.error('[AppLogger] Failed to write log:', err)
}
}
// ponytail: rename-based rotation, atomic, no rewrite, ~20MB total ceiling
private rotateIfNeeded(): void {
let size = 0
try {
size = fs.statSync(this.logFile).size
} catch {
return // file doesn't exist yet
}
if (size < MAX_SIZE_BYTES) return
fs.renameSync(this.logFile, this.oldLogFile) // overwrites previous .old, atomic
}
}
function resolveThreshold(): LogLevel {
const raw = (process.env.CHATLAB_LOG_LEVEL || '').toUpperCase()
if (raw === 'DEBUG' || raw === 'INFO' || raw === 'WARN' || raw === 'ERROR') return raw
return 'INFO'
}
function formatData(data: unknown): string {
if (data === undefined) return ''
if (data instanceof Error) {
const info = extractErrorInfo(data)
const stack = extractErrorStack(data)
return JSON.stringify(info) + (stack ? `\n${stack}` : '')
}
if (typeof data === 'string') return data
try {
return JSON.stringify(data)
} catch {
return '[unserializable data]'
}
}
function ensureDir(dir: string): void {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
}
let instance: AppLogger | null = null
/** Initialize the singleton app logger. Call once per runtime at startup. */
export function initAppLogger(logsDir: string): void {
instance = new AppLogger(logsDir)
}
/**
* The shared logger. Safe to call before init: logs are dropped silently until
* `initAppLogger` runs (avoids ordering hazards at very early startup).
*/
export const appLogger = {
debug: (scope: string, message: string, data?: unknown) => instance?.debug(scope, message, data),
info: (scope: string, message: string, data?: unknown) => instance?.info(scope, message, data),
warn: (scope: string, message: string, data?: unknown) => instance?.warn(scope, message, data),
error: (scope: string, message: string, data?: unknown) => instance?.error(scope, message, data),
getLogPath: () => instance?.getLogPath() ?? null,
}