Hy-Memory One Memory 诊断日志合同:跨 Desktop→Runtime→Core→HY→Host 的可追溯诊断体系
Hy-Memory One Memory 诊断日志合同:跨 Desktop→Runtime→Core→HY→Host 的可追溯诊断体系
导读
One Memory 诊断日志是 Hy-Memory 生态中一套面向排障的跨层日志合同:当用户体验出现问题后,可按最近时间、运行会话、单次操作或问题编号,还原 Desktop -> Runtime -> Core -> HY -> Host 的实际执行链。本文以 One Memory 诊断日志合同 为骨架,结合仓库中 Core 的 Python 实现(diagnostic_log.py)、Runtime/CLI 的 TypeScript 实现(diagnostic-log.ts、log-report.ts)以及对应测试,完整讲解事件格式、跨层关联规则、存储轮转与读取、覆盖节流策略和验收标准,帮助读者理解这套日志如何做到"既保隐私、又可排查、还不拖垮主链"。
一、设计目标:为"还原执行链"而生,而非为统计而生
合同开篇就界定了诊断日志的定位:它用于在用户体验出现问题之后,按最近时间、运行会话、一次操作或问题编号,还原 Desktop -> Runtime -> Core -> HY -> Host 的实际执行链。
与常见的用户行为分析、调用记录有本质区别,合同明确三条边界:
- 日志不是用户行为分析、不是调用记录、也不是记忆来源;
- 日志不进入
call_events,不写入记忆数据库(与记忆事件存储完全隔离); - 日志不上传网络,只留在本机 One Memory Home 中。
也就是说,这是一条"本机排障专用"的证据链:它在产品主链之外独立存在,随时可以被按需读取、导出和丢弃,但绝不允许反向污染产品数据。
从源码看,这一隔离在实现层面同样是刻意为之:Core 侧诊断日志的开关默认关闭(ONE_MEMORY_DIAGNOSTICS 环境变量未设置时 diagnostics_requested() 返回 False,configure_diagnostics 直接返回指向目录而非文件的 receipt,见 diagnostic_log.py);测试 test_diagnostic_log.py 中专门断言"关闭时必须连空文件都不产生"(list(tmp_path.iterdir()) == []),防止它变成一只不断长大的空文件。
二、事件格式:每行一个 DiagnosticEventV1 JSON 对象
每行日志是一个 DiagnosticEventV1 JSON 对象。TypeScript 侧的类型定义位于 runtime-control/src/diagnostic-log.ts,Core 侧 Python 实现的序列化起点是 DiagnosticLog._header()(diagnostic_log.py)。
必填字段
| 字段 | 说明 |
|---|---|
schema_version |
固定为 1 |
timestamp |
UTC 时间,ISO-8601,以 Z 结尾(源码中 datetime.now(UTC).isoformat().replace("+00:00", "Z")) |
level |
info / warn / error 三档(DiagnosticLevel) |
service |
写入该行的服务名:runtime、core、host-bridge、desktop、cli 五种之一 |
component |
产生事件的组件,如 core.api、import.supervisor、hy.bridge、memory.task |
event |
稳定事件名,如 import.finished、memory_task.finished |
outcome |
结果,如 started、succeeded、failed、rejected |
session_id |
本服务本次启动的会话 UUID |
可选关联字段
trace_id、incident_id、instance_id、operation_id、request_id、job_id、task_id、duration_ms、状态(state_before / state_after)、稳定错误码(error_code)、错误类型(error_type)、清理后的仓库相对栈帧(safe_stack)。
此外,TS 与 Python 两端还共同支持包身份字段 build_id、package_id、round——它们取自共享 spawn 点写入的环境变量(ONE_MEMORY_BUILD_ID、ONE_MEMORY_PACKAGE_ID、ONE_MEMORY_ROUND),让一条四层链路可以被归因到某个具体构建;源码运行(source run)没有包身份,因此不写入这些字段、也不编造(见 diagnostic-log.ts 与测试 test_diagnostic_log.py)。
一个典型事件的落盘形态如下(示例,字段与测试 test_diagnostic_log.py 中的事件一致):
{"schema_version":1,"timestamp":"2026-09-24T08:00:00.000000Z","level":"info","service":"core","component":"import.runner","event":"import.completed","outcome":"succeeded","session_id":"11111111-1111-4111-8111-111111111111","trace_id":"22222222-2222-4222-8222-222222222222","job_id":"job-1","duration_ms":42,"details":{"source_id":"codex","count":3}}
严格校验:白名单与拒绝写入
details 只接受代码声明的标量白名单。Core 侧白名单为 _DETAIL_KEYS(diagnostic_log.py),约七十个键,按用途可分为几类:
- 导入与任务计数:
stored_count、revised_count、skipped_count、error_count、pending_count、prepared_count、queue_depth、in_flight_count、call_count、created_count、updated_count、superseded_count、deleted_count; - 来源与操作标识:
source_id、operation_kind、mode、phase、action、method、platform、version、parent_session_id; - 状态与错误:
status、reason、failure_site、error_class、timed_out; - 资源指标:
open_file_count、memory_bytes、thread_count、database_error_count、sqlite_errorcode、sqlite_errorname; - 限界布尔开关:
spawned、index_complete、enabled等。
TS 侧维护了对应的 DETAIL_KEYS 集合(diagnostic-log.ts),并在 diagnosticEvent() 中校验事件名(EVENT_NAME)、标识符(IDENTIFIER,正则 ^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$)、duration_ms 非负整数、session_id 为合法 UUID 等。
禁止记录的内容包括:对话原文、查询、回答、模型输入输出、请求体、响应体、token、API Key、secret ref、完整用户路径,以及异常 message/cause。safe_stack 只允许仓库内相对栈帧:Core 的 diagnostic_exception() 从异常回溯中提取 one_memory 根之后的相对路径(相对路径:行号:函数名,最多 16 帧),而 _safe_stack_line 会拒绝任何绝对路径或含 file:// 的帧(diagnostic_log.py)。
未知字段与不合规标识符一律拒绝写入。同时合同规定:日志写入失败不能阻断产品主链。这一点在 diagnostic_event() 中兑现——捕获 OSError 与 ValueError 后直接返回,绝不让一次被拒绝的 payload 变成调用方的 500。测试 test_diagnostic_log.py 专门记载了历史教训:曾有两个上线的节点携带未列入白名单的 details 键,因为落在请求路径与循环心跳上,把外部召回变成了 500 并最终杀死进程;test_diagnostic_payload_keys.py 由此诞生——它用 AST 遍历 one_memory 包下每一个模块的每一次 diagnostic_event 字面调用,从源头保证白名单合规(见 test_diagnostic_payload_keys.py)。
三、关联规则:跨层链路如何被还原
1. 会话身份:每次启动一个 session_id
Desktop、Runtime、Core 各次启动有独立的 session_id。Core 侧 DiagnosticLog.__init__ 以 str(uuid4()) 生成(diagnostic_log.py),TS 侧 DiagnosticLogger 同样用 canonicalUuid(options.sessionId) ?? randomUUID()(diagnostic-log.ts)。CLI 读取时默认按"最近会话 + 最近的 instance_id"过滤,保证普通 memhub logs 看到的是当前这轮运行,而不是历史噪音(log-report.ts)。
2. 请求身份:trace_id 经 X-One-Memory-Trace-Id 传播
用户请求生成 UUID trace_id,经 HTTP 头 X-One-Memory-Trace-Id 在 Web、Broker 和 Core 之间传播;无效外来值被替换,不原样记录。
Core 侧通过 ASGI 中间件 diagnostic_trace 实现(api.py):
- 用
bind_trace_id(request.headers.get("X-One-Memory-Trace-Id"))绑定:若外来值不是合法 UUID,则替换为str(uuid4())(_uuid校验,见 diagnostic_log.py); - 请求开始与结束时分别写入
api.request.started/api.request.completed(4xx 以上记warn+rejected),异常时写api.request.failed并附带diagnostic_exception提取的error_type与safe_stack; - 响应头回写
X-One-Memory-Trace-Id,保证调用方拿到的是规范化后的 trace; trace_id存放于ContextVar,随异步上下文传播,请求结束后reset_trace_id还原。
测试 test_diagnostic_trace.py 验证:一次带秘密路径的请求,日志里只有 api.request.started 与 api.request.completed 两条且 trace 一致,秘密字符串不出现在任何一行;而导入任务能把这个 trace 一路带到后台 job(import.request.started → import.finished),同样不含原文。
3. 问题身份:incident_id 与时间窗口
"记录问题时间点"会创建 incident_id 并记录当前的 Runtime instance。TS 侧 DiagnosticLogger.markIncident() 负责生成 incident_id 并写入 incident.marked 事件(diagnostic-log.ts)。
合同规定的查询语义是:按 incident 查询时,返回同一桌面会话或同一 Runtime instance 在标记前 15 分钟至后 5 分钟的事件——即以问题时间点为锚,把前后两个方向的相关事件都捞回来,帮助还原"问题发生前发生了什么、发生后影响到了什么"。
4. 不创建平行身份
已有 operation/request/job/task 身份沿用业务合同,不创建平行身份。也就是说诊断日志只给两类新身份:跨请求的 trace_id 与跨会话的 incident_id,其余一律复用产品侧已有的 request_id、job_id、task_id 等,避免两套编号体系互相对不上。
四、存储与读取:追加式 JSONL,严格路径,有限预算
1. 文件位置与命名
日志位于 One Memory Home 的 logs/diagnostic-<service>.jsonl,服务包括 runtime、core、host-bridge、desktop 和 cli。Core 侧路径即 data_dir / "logs" / f"diagnostic-{self.service}.jsonl"(diagnostic_log.py)。
2. 轮转与清理预算
| 策略 | 数值 | 实现位置 |
|---|---|---|
| 单文件上限 | 4 MiB(MAX_FILE_BYTES) |
diagnostic_log.py |
| 轮转保留 | 3 份(.1 / .2 / .3),超出即淘汰最旧 |
_rotate()(L564-L572) |
| 过期清理 | 14 天以前的文件(MAX_AGE) |
_prune()(L574-L590) |
| 总容量上限 | 128 MiB(MAX_TOTAL_BYTES),超限按 mtime 从旧到新删 |
同上 |
TS 侧常量一致:DIAGNOSTIC_MAX_TOTAL_BYTES = 128 MiB、DIAGNOSTIC_MAX_AGE_MS = 14 天,写入前先 pruneDiagnosticLogs,文件超限即 rotate(diagnostic-log.ts)。
权限:POSIX 目录为 0700、文件为 0600。Core 侧 _write_batch 对父目录 os.chmod(0o700)、_append 对刚写入的文件描述符执行 os.fchmod(0o600)(避免路径被偷换后 chmod 到错误目标);TS 侧同样 mkdirSync(..., mode: 0o700) 与 chmod(target, 0o600)。
3. 防符号链接与固定文件名枚举
写入侧以 O_NOFOLLOW 打开文件,父目录或文件本身是符号链接时直接拒绝(_Refused,视为永久失败而非可重试);读取侧只枚举上述固定文件名的 regular file,不跟随符号链接、不接受任意路径。TS 侧 diagnosticLogFiles 用正则 ^diagnostic-(runtime|core|host-bridge|desktop|cli)\.jsonl(?:\.\d+)?$ 过滤,readDiagnosticEvents 跳过 symlink。
测试 test_diagnostic_log.py 中 test_old_logs_are_removed_but_symlinks_are_never_followed 专门验证:指向外部文件的 diagnostic-runtime.jsonl 符号链接永远不会被写入或读取,外部文件内容原封不动。
4. 损坏行处理与导出
读取时损坏行跳过并计数,不让一行损坏遮蔽其余证据。Core 的 read_trace_events 对每行 json.loads,失败即 continue(diagnostic_log.py);CLI 的 readLogReport 返回 invalid_lines 并在输出末尾提示"跳过 N 行损坏的诊断记录"(log-report.ts)。
诊断导出是单个脱敏 JSON,包含版本、平台、架构、非秘密状态,以及最多 5000 条已验证事件,不包含数据库或配置文件。trace 查询 API(POST /api/v1/diagnostics/trace)返回内容无感的投影:只允许 timestamp、component、event、outcome、duration_ms、error_code、error_type 及 details 中的 phase/mode/status/reason/failure_site/count(diagnostic_log.py),每次最多返回 500 条。
5. 追加式写入的可靠性与节流细节
Core 侧把"热路径"刻意压缩为两件事:把事件转成一行、交给队列。磁盘相关的一切(目录检查、清理、轮转、写入)都由一个专属写线程完成(模块 docstring 明确记录了历史教训:旧的每事件目录扫描 + 全盘 fsync 曾让事件循环被阻塞 2.0~2.8 秒)。关键机制:
- 队列上限
MAX_QUEUED_EVENTS = 4096,溢出时丢弃并记账,写入diagnostic.events_dropped事件(含count与时间窗口duration_ms),让读者能区分"无事发生"与"这段被扔掉了"(_dropped_line); - 写失败重试最多
MAX_WRITE_ATTEMPTS = 3次、退避 50ms,仍失败则置degraded并记账; - 清理扫描每
PRUNE_INTERVAL_SECONDS = 60秒一次,按天/兆字节量级的预算无需逐事件扫描; - 正常关停时恰好执行一次
_final_sync(fsync),作为整份 telemetry 的耐久性承诺;flush()/close()只有在确认批次真正落盘且未降级时才返回True。
测试 test_diagnostic_log_durability.py 以故障注入验证了这批承诺:磁盘坏时 flush() 不得谎报成功、盘恢复后事件要么补齐要么明确记账(留存 + 丢弃 == 发出)、os.write 短写不得留下半行 JSON、关停前不得出现多余的 fsync、卡住的写线程下两次 close() 都不得谎报成功。
五、覆盖与节流:记什么、不记什么
必须记录
启动/停止、attach/spawn、身份核验、导入和记忆任务状态、模型/HY 调用、宿主召回、权限与安装操作、关键 IPC、失败分类、重试、耗时和终态。
不记录
无变化的高频轮询、逐条原文;导入使用阶段和计数汇总(而非逐条明细),避免日志反过来造成性能问题。
高频事件汇总
info 级别的三个高频事件(import.phase_changed、import.parse.started、context_stage.completed)在 300 秒窗口内按 (component, event, outcome, state, source_id, operation_kind, phase) 维度聚合,同维度只落一条、累计 count 与 summed_ms;warn 及以上不受影响(HIGH_FREQUENCY_SUMMARY_SECONDS = 300,见 diagnostic_log.py)。测试 test_diagnostic_log.py 中 test_high_frequency_success_events_are_summarized_but_warnings_are_not 验证:100 次 info 只落一条带 count=100 的汇总,而中间那条 warn 原样保留。
产品进度判断:四个稳定事件
| 稳定事件 | 关键计数 | 产生位置 |
|---|---|---|
import.finished |
stored_count、revised_count、skipped_count、error_count |
imports/supervisor.py |
hy.ingest.completed |
SDK 本次返回的 result_count |
hy_memory_bridge.py |
hy.reconcile.completed |
新增、更新、取代、删除数量(created_count/updated_count/superseded_count/deleted_count) |
hy_response_guard.py |
memory_task.finished |
最终 result_count、剩余 pending_count 和 attempt |
memory_processing.py |
合同特别强调:memory_task.finished 是产品侧判断沉淀是否持续推进的最终事件;不能只用 hy.ingest.completed 代替,因为后者早于 Governance 写入——一次 ingest 成功不代表写库、审核、沉淀链路全部收尾,只有记忆任务走到终态才算数。
六、读取与排查:Core API 与 CLI 双通道
Core 诊断 API
Core 在 api.py 暴露四组受保护端点:
GET /api/v1/diagnostics/settings、PUT /api/v1/diagnostics/settings:查询/切换详细日志(detailed_logging)与当前进程日志级别;PUT /api/v1/diagnostics/log-level:设置info/debug级别(未知级别返回 422);POST /api/v1/diagnostics/trace:按request_id或trace_id查询脱敏投影事件,state为disabled(未开启详细日志)/ok/not_found;GET /api/v1/diagnostics/model-usage:返回不含内容的模型用量汇总(request_count、prompt_tokens、completion_tokens、reasoning_tokens等),用于 provider 对账。
CLI memhub logs
CLI 通过 memhub logs 子命令读取日志(cli.ts),查询参数解析在 log-report.ts:
| 参数 | 含义 |
|---|---|
| `--since <15m | 2h |
--session <uuid> / --trace <uuid> / --incident <uuid> |
按会话、trace、incident 过滤(要求 UUID) |
--operation / --request / --job / --task |
按业务身份过滤 |
--component <name> / --event <name> |
按组件/事件名过滤(小写点分命名) |
--errors-only |
只看错误 |
--tail <n> |
条数(1~5000,默认 200) |
--follow |
持续跟随新事件 |
--json |
JSON 行输出 |
CLI 对读取面做了第二道脱敏防线(redactDiagnosticEvent):即使未来某行携带了未声明键或可疑栈帧,memhub logs 也只放行白名单键、过滤绝对路径与 .. 栈帧,防止日志读取变成信息外泄路径。memhub logs export 则将最近 7 天诊断日志、最近 24 小时崩溃报告与不含正文的 summary.json 打成 zip 到 Downloads(或 ONE_MEMORY_EXPORT_DIR),包内主目录统一替换为 ~(diagnostic-log.ts)。
CLI 自身还会写 diagnostic-cli.jsonl,但对隔离 HOME(无 --data-dir 且 HOME 不是真实账户主目录)的场景会跳过入口诊断,避免把日志写进调用方并不打算作为用户主目录的隔离根(entry-diagnostics.ts)。
七、验收标准:隐私正向对照 + 故障还原
合同的验收要求分为两层:
1. 隐私测试必须先植入正向对照。 在测试前先植入一条可检测的秘密(如 secret = "private-provider-response-918273"),再证明真实链路日志与导出均不含该秘密。这正是 test_diagnostic_log.py 与 test_diagnostic_trace.py 的做法:test_exception_diagnostic_keeps_code_location_not_message 断言 error_type == "ValueError"、栈里含 one_memory/diagnostic_log.py 的位置信息,而秘密字符串不在序列化结果中;导入场景则断言对话原文秘密不出现在任何一行日志里。
2. 用隔离数据制造三类失败并仅凭日志还原。 至少制造 Core 启动失败、HY 失败和宿主连接失败,并仅凭日志还原:失败组件、时间、关联 ID、稳定错误码和代码位置。这一能力由事件里的 component、timestamp、trace_id/job_id 等关联 ID、error_code(如 database_locked、import_failed)与仓库相对 safe_stack 共同支撑;数据库错误还会分类为 database_locked/database_busy/database_cannot_open/database_readonly/database_disk_io/database_corrupt/database_other 并计数(record_database_error,diagnostic_log.py)。
3. 验收分级。 自动测试通过只能标记 verified;正式安装 App 的真实体验仍需单独验收——即自动化证明的是合同与实现的合规,真实用户在真机上的排障体验不在自动测试范围内。
小结
One Memory 诊断日志合同把"排障证据链"做成了产品内的一个受控子系统:用白名单与拒绝写入守住隐私底线,用 trace_id/incident_id/session_id 三层身份串起四层执行链,用 4 MiB × 3 份轮转与 128 MiB 总量把磁盘占用锁进预算,用单写线程队列把日志对主链的影响降到最低,再用稳定的 import.finished、hy.ingest.completed、hy.reconcile.completed、memory_task.finished 事件支撑产品侧的沉淀进度判断。无论是通过 Core 的诊断 API、CLI 的 memhub logs 查询,还是 logs export 打包导出,这条链路都始终围绕同一个目标:问题发生后,你能在本机、只在本机,把那条真实执行链完整地找回来。