ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CLI 错误诊断模式与详细日志转储

CLI 错误诊断模式与详细日志转储 CLI 错误诊断模式与详细日志转储开源 CLI 工具上线后最让人抓狂的反馈莫过于 GitHub Issue 里只有一句冷冰冰的报错“运行报错了怎么解决”附带的截图可能只截取了控制台最后一行没有任何上下文的Error: Request failed with status 500或者TypeError: Cannot read properties of undefined。在命令行交互场景下向终端屏幕输出的内容必须追求极简、整洁避免满屏的堆栈信息破坏正常交互体验但在程序崩溃或请求异常时排查问题又需要极其详尽的环境参数、网络请求体、响应头以及完整执行时序。为了解决这个矛盾我们需要在 CLI 核心运行时中引入双轨日志机制交互层只展示高提炼的错误摘要底层则将全量调试追踪信息静默持久化到本地临时目录并通过--debug参数提供即时诊断能力。终端交互与故障诊断的矛盾很多初做 CLI 工具的开发者容易走入两个极端直接生吞错误为了让控制台看起来干净使用try...catch抓取异常后仅仅console.error(执行失败请重试)导致现场信息彻底丢失用户反馈时开发者无法还原场景。直接抛出原始堆栈一旦出错几十行的 Node.js 或 Go 错误调用链直接刷屏包含大量第三方依赖库内部的匿名函数调用普通用户看不懂且感到恐惧真正的业务错误信息反被淹没。合理的解法是终端只看现象磁盘记录全貌。正常执行只输出简洁的错误提示与修复引导同时生成一份独立的调试转储文件Crash Dump并告诉用户转储文件的物理路径用户提 Issue 时只需上传该文件即可。运行时诊断架构设计在轻量级 CLI 工具中引入复杂的日志框架如 Winston、Pino 等往往会导致包体积膨胀或启动耗时增加 30ms 以上。对于 CLI 这种追求毫秒级冷启动的工具使用原生模块构建一个百行以内的轻量 Logger 足以胜任。诊断系统主要由三部分组成内存环形缓冲区Ring Buffer记录近 500 条操作日志避免高频写入磁盘带来 I/O 开销。崩溃落盘拦截器Crash Flusher在process.on(uncaughtException)、process.on(unhandledRejection)以及主动捕获的严重错误点将内存日志、系统环境、配置快照一次性写入本地日志文件。命令行开关--debug/-v开启时将原本静默记录的 Trace 级别日志实时同步输出至终端 stderr。TypeScript 最小化落地实现下面是 CLI 诊断模块的核心实现代码不依赖任何第三方重量级日志库保证冷启动零负担import fs from node:fs; import path from node:path; import os from node:os; export type LogLevel trace | info | warn | error; interface LogEntry { timestamp: string; level: LogLevel; tag: string; message: string; meta?: Recordstring, unknown; } export class DiagnosticLogger { private static instance: DiagnosticLogger; private logs: LogEntry[] []; private readonly maxBufferSize 500; private isDebugMode false; private logDir: string; private constructor() { this.logDir path.join(os.homedir(), .mycli, logs); this.isDebugMode process.argv.includes(--debug) || process.argv.includes(-v); this.ensureLogDirectory(); } public static getInstance(): DiagnosticLogger { if (!DiagnosticLogger.instance) { DiagnosticLogger.instance new DiagnosticLogger(); } return DiagnosticLogger.instance; } private ensureLogDirectory(): void { try { if (!fs.existsSync(this.logDir)) { fs.mkdirSync(this.logDir, { recursive: true, mode: 0o700 }); } } catch { // 降级使用系统临时目录 this.logDir os.tmpdir(); } } public record(level: LogLevel, tag: string, message: string, meta?: Recordstring, unknown): void { const entry: LogEntry { timestamp: new Date().toISOString(), level, tag, message, meta, }; this.logs.push(entry); if (this.logs.length this.maxBufferSize) { this.logs.shift(); } if (this.isDebugMode) { const colorMap { trace: \x1b[90m, info: \x1b[36m, warn: \x1b[33m, error: \x1b[31m, }; const reset \x1b[0m; const formattedMeta meta ? ${JSON.stringify(meta)} : ; process.stderr.write( ${colorMap[level]}[${entry.timestamp}] [${level.toUpperCase()}] [${tag}]${reset} ${message}${formattedMeta}\n ); } } public dumpCrashReport(error: Error, extraContext?: Recordstring, unknown): string { const timestamp Date.now(); const fileName crash-${timestamp}.log; const filePath path.join(this.logDir, fileName); const report { cliVersion: 1.2.0, nodeVersion: process.version, platform: ${os.platform()} (${os.arch()}), cpu: os.cpus()[0]?.model || unknown, memoryUsage: process.memoryUsage(), error: { name: error.name, message: error.message, stack: error.stack, }, extraContext: extraContext || {}, executionHistory: this.logs, }; // 写入前脱敏敏感字段如 token、password const sanitizedReport this.sanitize(JSON.stringify(report, null, 2)); fs.writeFileSync(filePath, sanitizedReport, { encoding: utf-8, mode: 0o600 }); return filePath; } private sanitize(raw: string): string { return raw .replace(/(sk-[a-zA-Z0-9]{20,})/g, sk-***REDACTED***) .replace(/(bearer\s)[a-zA-Z0-9._-]/gi, $1***REDACTED***) .replace(/(password\s*:\s*)[^]/gi, $1***REDACTED***); } }全局异常捕获与脱敏策略在 CLI 入口文件处注册全局监听器当遭遇未捕获异常时执行格式化打印并指引排查import { DiagnosticLogger } from ./logger; const logger DiagnosticLogger.getInstance(); export function setupErrorHandlers(): void { const handleFatal (err: unknown, origin: string) { const errorInstance err instanceof Error ? err : new Error(String(err)); const dumpPath logger.dumpCrashReport(errorInstance, { origin }); console.error(\n\x1b[31m✖ 执行过程中发生异常崩溃\x1b[0m); console.error( 错误原因: ${errorInstance.message}); console.error(\n\x1b[33m 详细调试信息已保存至:\x1b[0m ${dumpPath}); console.error( 提交 Issue 时请将上述日志文件内容一并附上以便快速定位问题。\n); process.exit(1); }; process.on(uncaughtException, (err) handleFatal(err, uncaughtException)); process.on(unhandledRejection, (reason) handleFatal(reason, unhandledRejection)); }敏感数据过滤与日志轮转控制在开发诊断转储功能时安全边界是不可逾越的红线。许多开发者在排查网络请求时习惯把 Request Headers 与 Request Body 完整记录这极易导致用户的 API Token、私有鉴权 Cookie 或者个人密钥泄漏到公开 Issue 中。强行脱敏正则必须在最终写入磁盘前对文本内容执行正则替换对已知供应商的 Token 格式例如 OpenAI 的sk-...密钥、JWT 令牌等做掩码替换。严格控制日志目录生命周期避免日志无休止占用用户磁盘空间。在每次写入新转储文件时只保留最近 10 个 crash 文件旧文件按创建时间排序直接清理。export function pruneOldLogs(logDir: string, maxFiles 10): void { try { const files fs.readdirSync(logDir) .filter((f) f.startsWith(crash-) f.endsWith(.log)) .map((f) { const full path.join(logDir, f); return { path: full, mtime: fs.statSync(full).mtimeMs }; }) .sort((a, b) b.mtime - a.mtime); if (files.length maxFiles) { for (const item of files.slice(maxFiles)) { fs.unlinkSync(item.path); } } } catch { // 忽略清理阶段的失败不阻断主流程 } }总结CLI 软件运行在千差万别的用户本地环境中不同 Node 运行时、不同系统架构、各异的代理网络环境以及权限隔离。通过构建“内存轻量缓存 崩溃脱敏落盘 --debug实时透传”的诊断体系既能守护日常终端界面的清爽又能在遇到复杂故障时拿到完整的执行现场大幅降低与社区用户的沟通成本。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进