# log_design:Eyrie 日志系统的近期设计 ## 这份文档是什么 本文是 Eyrie 日志系统的**设计方案**,是 [`log_study.md`](./log_study.md) 的兄弟篇。分工: - `log_study.md` 管「一般范式」——从 `ref/` 成熟项目归纳日志系统通常怎么设计,不含 Eyrie 实现。 - 本文管「Eyrie 落法」——把那些范式落到 `eyrie/main` 的具体代码上,给出近期要建什么、刻意不建什么。 写作标准沿用 study:每个设计决定都挂 **范式编号**(引 study 的范式一~七)+ **Eyrie 当前缺口**(已核对 `eyrie/main` 代码)+ **证据**(`ref/...` 或 `eyrie/...` 文件路径)。判断分事实 / 推断 / 边界三档,不混。 - 边界:本文只覆盖**近期设计**。埋点上报、top-N 错误分析、远端 collector 属于「更长远」,本文只为它们保留结构上的 seam,不给实现,也不预测它们的具体形态。 - 边界:核对范围是 `eyrie/main` worktree,不代表所有分支。 ## 设计目标 回应三个一手诉求,外加 study 范式六补的一条: 1. **组件**——日志能按模块定位(`agent.claude.runner` vs `daemon.http`),排查时按组件筛。 2. **保存**——日志落盘、轮转、有保留策略;错误能在 app 内回看。 3. **规范**——什么值得记、怎么记,写进 `AGENTS.md`,让后续新代码自然合规。 4. **可测试**(study 范式六,我前一版漏了)——logger 可替换,测试里意外 error 不被静默吞掉。 ## 刻意不做(保 seam,不建实现) 这是回应「终局不可忽略、但当前不实现」的正解——**不靠提前建实现来不忽略终局,靠结构上留口子**。study 启示 6 与 `ref-projects.md` 都明确警告:别为「看起来完整」提前搬大项目全套设施,别在 roadmap 里写死参考谁。 | 终局能力 | 现在不建 | 留下的 seam | |---|---|---| | 平台索引 / 检索 | 不接日志平台 | 结构化字段(范式一)已就位,未来直接被索引 | | 选择性上报闸门 | 不建 telemetry sink | level gate(范式二)已在,未来复用作上报闸门 | | SQLite `error_events` / 远端 collector | 不写错误表 | sink 与业务代码解耦(范式四),未来加 sink 改根配置即可 | | top-N / 量级趋势 | 不做聚合 | 这是个新的具体问题;真要做时按 `ref-projects.md` 再去翻 phoenix/langfuse 的事件 schema,现在翻就是预测 | ## 核心架构:一个包装层串起三诉求 study 范式三/四的核心是「业务代码只发事件,根部决定去向」。Eyrie 现在缺的正是这个根部抽象——`log.ts` 只导出一个裸 pino 单例(事实:`eyrie/main/apps/daemon/src/log.ts` 全文仅 19 行,导出 `logger` 和 `Logger` 类型,无 child、无 redact、无文件 sink)。 近期设计的全部重量压在**一个包装层**上: ``` getLogger('agent.claude.runner') // 组件:child logger 绑定 { component },解析 per-component level │ 调用点只写:log.info({ sessionId }, 'turn started') 静态消息 + 字段(范式一) │ ├─→ pino root(带 redaction、serializers、base 字段) 根部统一安全过滤(范式五) │ ├─→ stdout dev 走 pino-pretty,prod 裸 JSON(保留现状) │ └─→ file transport 轮转 JSONL → ~/.eyrie/logs/daemon-*.jsonl(范式四,新增) │ └─(测试环境)→ test logger / NullLogger 可替换(范式六) ``` 「app 内看错误」**不在这条链上加分支**,而是独立的诊断回看通道(见后文「错误诊断」一节)——study 启示 3 明确:诊断日志不能和 `EphemeralLog` 产品事件流混概念。 包装层一处搞定三诉求:`getLogger(component)` 解决**组件**;root 的 file transport 解决**保存**;包装层的接口形态 + `AGENTS.md` 条目解决**规范**;test logger 解决**可测试**。 ## 设计决定逐条(范式 + 缺口 + 证据) 每行「缺口」均为一手核对的 `eyrie/main` 代码现状。 | # | 设计决定 | 范式 | Eyrie 当前缺口(已核对) | 证据 / 参照 | |---|---|---|---|---| | D1 | `getLogger(component)` 返回绑定 `{component}` 的 child logger | 三 上下文绑定 | `log.ts` 仅单例无 child;codex provider 已 `import { logger }` 直用,无组件维度 | Pino `child-loggers.md`;Temporal `WithLogger`;`eyrie/.../codex/index.ts:5` | | D2 | 请求边界绑 child logger 带 `requestId`,info/debug 也带 | 三 / 七 | `request-context.ts` 有 requestId,但仅 `app.ts` `.onError` 手写进字段,普通日志不带——正中 study 检查题 Q3 | `app.ts:98-128`;`request-context.ts:33-40`;范式七 | | D3 | per-component level + 全局 `EYRIE_LOG_LEVEL` 兜底 | 二 级别闸门 | 只有全局 level(`log.ts:6`),无法只对一个模块开 debug | VS Code `canLog`;Paseo 最小 level 计算 `logger.ts` | | D4 | 文件 sink + 轮转,配在 root transport,调用点零改动 | 四 去向解耦 | **完全无文件 sink**,只 stdout——daemon 重启即失忆 | Pino `transports.md`(worker thread);VS Code `FileLogger` 5MB 轮转 | | D5 | root 配 redaction,禁止把 prompt / 用户代码 / token 绑成字段 | 五 安全先行 | `log.ts` **无任何 redact**;daemon token、auth header、agent prompt 都是泄漏面 | Pino `redaction.md`;Paseo redacts `authorization` | | D6 | fatal 路径改用 `logger.fatal`(不再 `logger.error`) | 二 | `index.ts` 锁冲突 / 启动失败 / fatal startup 全用 `logger.error`;pino 仅 `fatal` 同步 flush,崩溃日志才不丢 | Pino `lib/levels.js` fatal sync-flush;`index.ts:57,176` | | D7 | `NullLogger` + test logger;定「意外 error log 是否 fail 测试」 | 六 可测试 | 无任何日志测试替身;vitest setup 不感知日志 | Temporal `testlogger.go`;VS Code `NullLogger` | | D8 | desktop main 自起 root logger → `~/.eyrie/logs/desktop-*.jsonl`,`createMainWindow` 与 daemon 凭证桥包 try/catch | 七 运行边界 | `main/index.ts` **零日志、零 try/catch**,主进程崩溃全黑盒 | 范式七;`main/index.ts:53-61`;`daemon-bridge.ts` | | D9 | 进程退出 / spawn 失败边界统一记结构化字段 | 七 | claude `process.ts` stderr「drained for diagnostics only」未入 logger;`terminal-ws` pty exit 只 `sendJson` 不记日志;`runner-manager.handleRunnerExit` 无日志 | `process.ts:101-113`;`terminal-ws.ts:143-214`;`runner-manager.ts:92` | | D10 | 迁移边界包日志(开始 / 完成 / 失败) | 七 | `db/client.ts:35` `migrate()` 裸调用,迁移失败无结构化痕迹 | 范式七 background migration;`db/client.ts` | | D11 | 规范写进 `AGENTS.md`:level 语义 / component 命名 / 各边界记什么 / redaction / 静态消息+字段 / 诊断≠EphemeralLog | 全范式 | `AGENTS.md:41` 仅一句 "pino logging" | study 全文;`AGENTS.md:41` | ## 保存:落盘、轮转、保留 study 范式四的边界很清楚——**「写日志的代码」稳定不动,「写哪、怎么转、留多久」是 root 配置**。所以下面的参数全是 root transport 的旋钮,改它们不碰任何调用点。 **落盘位置**:`~/.eyrie/logs/`(事实:`EYRIE_HOME` 默认 `~/.eyrie`,已放 `eyrie.db` / `eyrie.lock` / runtime token,见 `index.ts:27-29`)。日志放同目录的 `logs/` 子目录,与现有数据布局一致。 - daemon:`~/.eyrie/logs/daemon-*.jsonl` - desktop main:`~/.eyrie/logs/desktop-*.jsonl`(D8) **轮转机制**:用 `pino-roll`(daemon 现有依赖只有 `pino` + `pino-pretty`,事实:`apps/daemon/package.json:26-27`,**需新增轮转 transport**)。pino 官方 transport 跑在 worker thread,不阻塞业务主线程(证据:Pino `transports.md`)。 **轮转参数(建议默认,非硬编码——见「待拍板」)**:以 VS Code `FileLogger` 5MB 备份为先例锚定(证据:`ref/vscode/.../fileLog.ts`),给一组有依据的起点: - 单文件上限:`10MB`(比 VS Code 略大,因 daemon JSONL 比 VS Code 文本日志信息密度低)。 - 保留份数:`10` 份(约 100MB 上限),或按天 `14` 天,二选一。 - 同步 / 异步:dev 同步(pretty 即时可见);prod 异步 buffer 批量写(范式四:降开销,但崩溃可能丢最近 buffer——证据 Pino `asynchronous.md`)。**fatal 路径强制同步 flush**,所以 D6 的 fatal 级别修正不只是语义正确,也保证崩溃日志不丢。 **推断**:JSONL(每行一个 JSON 对象)比 pretty 文本更适合落盘——它既是 pino prod 的原生输出(保留现状),又能被未来的诊断回看路由逐行解析,无需引第二种格式。 ## 错误诊断:app 内回看(独立通道,非产品 bus) 这是「怎么在 app 内看错误信息」的近期答案。**关键纪律**(study 启示 3):诊断日志不能复用 `EphemeralLog`——后者是面向 UI 的产品订阅事件流(事实:`trpc/ephemeral-log.ts` 的 `createEphemeralTopicBus` 是非重放 live fan-out),把诊断错误塞进去是概念污染,我前一版方案犯过这个错,此处纠正。 近期采用 **VS Code `FileLogger` 式的回看**(证据:`ref/vscode/.../fileLog.ts`): 1. 错误已经落在 `daemon-*.jsonl` 里(D4),不需要第二份存储。 2. daemon 加一条**诊断专用路由**(HTTP 即可,与现有 health/shutdown 同层),读最近的轮转文件,逐行解析 JSONL,按 `level >= error` / `component` / 时间窗过滤后返回。 3. renderer 加一个**诊断面板**消费它,展示最近错误列表 + 字段(`component` / `code` / `requestId` / `msg`)。 **为什么够用**:近期诉求是「能看到错误」,文件回看就满足,且零新存储、零新概念。 **边界**:这条通道**不做** top-N 聚合、不做实时推流、不做跨重启的快速检索。那些要可查询存储(SQLite)或聚合扫描,属于「更长远」,本文只在「刻意不做」里留了 sink 解耦的 seam(D4 的 root transport 可加第二 sink),不在近期建。真要做时是个新的具体问题,按 `ref-projects.md` 再去翻 phoenix/langfuse。 ## 多进程覆盖 study 范式七:日志要贴近系统入口。Eyrie 是 daemon + desktop + CLI 三进程,覆盖策略按「是否当下黑盒」分级: | 进程 | 近期动作 | 理由 | |---|---|---| | **daemon** | 全量上:包装层 + 文件 sink + redaction + 边界补齐(D1-D7,D9,D10) | 主进程,已有 pino 地基,补齐成本最低 | | **desktop main** | 自起独立 root logger 写 `desktop-*.jsonl`,关键边界包 try/catch(D8) | 当下**全黑盒**,属「补现有缺口」。两个 Node 进程不能共用一个 pino 实例,故各写各的文件;daemon 挂了 desktop 日志仍在 | | **renderer** | 近期不做上报通道 | React 错误经 RPC 回传 daemon 入库是**新增面**,且依赖一个尚不存在的可查存储;压后 | | **CLI** | 仅补一点:`eyrie serve` 记 daemon **异常退出码** | consola 是**用户输出**不是诊断日志(保持区分,范式五 Paseo coding-standards);但 `serve.ts:32` 现在 daemon 子进程崩了只 `process.exit(code)` 不留痕,补一条结构化退出日志 | **推断**:desktop main 与 daemon 各写各的文件,是 study 范式四「sink 解耦」在多进程下的自然结果——logger 根部知道自己写哪,业务代码不关心。 ## 可测试性(study 范式六,前一版漏了) study 范式六:成熟项目让日志**参与测试和替换**,而不是当成测试之外的副作用。Eyrie 测试纪律重(事实:`vitest.config.ts` + `vitest.setup.mjs` 全局生效,`testing/index.ts` 已有 daemon 测试 harness),但当前**无任何日志测试替身**。 近期补三件(D7): 1. **`NullLogger`**——满足 `Logger` 接口但不输出,供不关心日志的单测注入(证据:VS Code `NullLogger` / `NullLogService`)。 2. **test logger**——把日志接到测试断言面,让测试能验证「该记的记了」(证据:Temporal `testlogger.go`)。 3. **「意外 error log 是否 fail 测试」的决定**——Temporal `testlogger` 在意外 error / DPanic / fatal 时让测试失败,把「悄悄打了条 error」变成显式信号。Eyrie 要不要采纳这条强约束,列入「待拍板」。 **推断**:`getLogger(component)` 一旦成为唯一入口(而非到处 `import { logger }`),注入替身就只需在测试边界换一次工厂,不必逐个调用点 mock——这也是 D1 把单例改成工厂的附带收益。 ## 规范条目(写入 AGENTS.md 的拟稿) D11 的产出。下面是建议写进 `AGENTS.md` 的规范正文(英文,遵守跨仓边界——`eyrie/` 源码 / 日志串全英文)。此处先以中文说明每条意图,定稿时落英文条目。 **level 语义**(范式二,对齐 study 而非另造): - `fatal`——daemon 无法继续或核心不变量破坏(锁冲突、DB 打不开、端口绑定失败)。**唯一会同步 flush 的级别**。 - `error`——当前操作失败需排查,但进程未必退出(已处理的 AppError、unhandled error、provider 进程异常退出)。 - `warn`——出现异常倾向但已恢复 / 降级(重试、慢请求、丢弃畸形输入——如 codex `discarded malformed line`)。 - `info`——正常生命周期节点(daemon listening、db opened、token ready、shutdown、迁移完成)。 - `debug`——开发 / 深度诊断(每请求、每消息细节;Hono request log 现已是 `logger.debug`)。 - `trace`——最细路径,仅临时排查打开(原始 stream-json 流等)。 **component 命名**(范式三):点分层级 `domain.module.unit`,如 `daemon.http` / `agent.claude.runner` / `agent.codex` / `db.migrate` / `desktop.main`。点分的理由:未来按前缀 rollup(`agent.*` 整体)零成本,扁平命名做不到。 **静态消息 + 字段**(范式一):消息是稳定字符串,动态值进字段。禁止 `` `failed for ${userId}` `` 这种拼接(证据:Temporal `msg should be static`)。同类事件不因变量分裂、字段可被索引、敏感值集中过滤。 **各边界记什么**(范式七):HTTP 边界记 requestId(D2);进程 spawn / exit 记 command + code + signal + 最后一行 stderr(D9);迁移记开始 / 完成 / 失败(D10);daemon 生命周期记启动 / 监听 / shutdown(现有,补 fatal 级别 D6)。 **redaction**(范式五):root 配 redact paths 遮盖 `authorization` / daemon token / auth header(证据:Paseo redacts `authorization`)。**绝不记**:agent prompt 正文、用户源码 / 文件内容 / diff、运行时 token、PII。redact path 不可来自用户输入(证据:Pino `redaction.md` 警告)。 **诊断 ≠ EphemeralLog**(study 启示 3):写一条明确规范——`EphemeralLog` 是产品订阅事件流,诊断日志走 `getLogger`,两者概念不混。防止后人重蹈我前一版的污染。 **禁 console**:除 renderer 现有两处 i18n / optimistic-mutation 的 `console.error`(待评估迁移),新代码不留 `console.log` / `debugger`(证据:Paseo coding-standards)。 ## 主动补一次现有日志(回扫清单) 「后续写进 agents.md,也要主动补一次现有的日志」——规范定稿后按它回扫现有代码。这是**改造动作清单**,不是新设计: 1. **改单例为工厂**:`log.ts` 导出 `getLogger(component)`;codex provider 现有 `import { logger }`(`codex/index.ts:5` 等 6 处)迁到 `getLogger('agent.codex')`。 2. **补 requestId 绑定**:daemon 请求中间件建 child logger 带 requestId(D2),向下传递。 3. **修 fatal 级别**:`index.ts:57,176` 锁冲突 / fatal startup 改 `logger.fatal`(D6)。 4. **补进程边界**:claude `process.ts` stderr 入 logger、`terminal-ws` pty exit、`runner-manager.handleRunnerExit`、claude / codex 进程退出码(D9)。 5. **补迁移日志**:`db/client.ts:35` `migrate()` 包开始 / 完成 / 失败(D10)。 6. **补 desktop main**:起 logger、关键边界 try/catch(D8)。 7. **补 CLI**:`serve.ts:32` daemon 异常退出码记结构化日志。 8. **配 redaction + 查泄漏**:root 配 redact;扫现有日志调用点有无 token / prompt / 用户代码泄漏(范式五)。 9. **加测试替身**:`NullLogger` + test logger 接入 vitest setup(D7)。 ## 待拍板 写代码前需要你定的旋钮 / 取舍(其余我按上文建议默认推进): 1. **轮转保留**:按份数(10 份 ~100MB)还是按天(14 天)? 2. **单文件上限**:10MB 这个锚点 OK 吗(VS Code 是 5MB)? 3. **意外 error log 是否 fail 测试**(D7 / 范式六):采纳 Temporal `testlogger` 那条强约束,还是只做被动断言不强制失败? 4. **renderer 两处现有 `console.error`**(i18n / optimistic-mutation):本次一并迁到规范通道,还是留到 renderer 上报那一期统一处理? 5. **CLI 补日志的边界**:只记 daemon 异常退出码,还是 `serve` 也落一份独立文件日志?(我倾向只记退出码,保持 CLI 轻。) ## 证据索引 本文主要判断的支撑文件,不是全部搜索结果。 **Eyrie 现状(一手核对)**: - `eyrie/main/apps/daemon/src/log.ts`:当前裸 pino 单例,无 child / redact / 文件 sink。 - `eyrie/main/apps/daemon/src/app.ts:98-128`:`.onError` 手写 requestId / code / status,普通日志不带 requestId。 - `eyrie/main/apps/daemon/src/index.ts:27-29,57,176`:EYRIE_HOME 布局;锁冲突 / fatal startup 误用 `logger.error`。 - `eyrie/main/apps/daemon/src/request-context.ts:33-40`:requestId / clientRequestId / source 来源。 - `eyrie/main/apps/daemon/src/agent/providers/codex/index.ts:5`:现有 `import { logger }` 直用,无组件维度。 - `eyrie/main/apps/daemon/src/agent/providers/claude/process.ts:101-113`:stderr「drained for diagnostics only」未入 logger。 - `eyrie/main/apps/daemon/src/terminal/terminal-ws.ts:143-214`:pty spawn 失败 / exit 只 `sendJson`,不记日志。 - `eyrie/main/apps/daemon/src/agent/runner-manager.ts:92`:`handleRunnerExit` 无日志。 - `eyrie/main/apps/daemon/src/db/client.ts:35`:`migrate()` 裸调用。 - `eyrie/main/apps/daemon/src/trpc/ephemeral-log.ts`:`EphemeralLog` 是产品 live fan-out,非诊断日志。 - `eyrie/main/apps/desktop/src/main/index.ts:53-61`:主进程零日志 / 零 try/catch。 - `eyrie/main/apps/cli/src/commands/serve.ts:32`:daemon 子进程崩溃只 `process.exit`。 - `eyrie/main/apps/daemon/package.json:26-27`:仅 `pino` + `pino-pretty`,无轮转库。 - `eyrie/main/AGENTS.md:41`:日志规范当前仅一句 "pino logging"。 **ref 参照(范式来源,详见 log_study 证据索引)**: - `ref/pino/docs/transports.md`、`asynchronous.md`、`redaction.md`、`child-loggers.md`;`ref/pino/lib/levels.js`。 - `ref/vscode/.../fileLog.ts`(5MB 轮转)、`log.ts`(NullLogger)。 - `ref/temporal/.../testlogger.go`(test logger / unexpected error fail)、`interface.go`(静态 msg)。 - `ref/paseo/.../logger.ts`(root logger / redaction / 文件输出)、`coding-standards.md`(禁 console / 区分用户 copy)。 - `ref/phoenix/.../_config.py`(stdout/stderr 分流);`ref/langfuse/.../logger.ts`(trace/span 注入)。 - `eyrie-inhouse/ref-projects.md`:取其神韵不照搬 / 不预判参考谁。