Hy-Memory One Memory 诊断日志合同:跨 Desktop→Runtime→Core→HY→Host 的可追溯诊断体系

原创2026-10-19 21:02:541,723 阅读

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 打包导出,这条链路都始终围绕同一个目标:问题发生后,你能在本机、只在本机,把那条真实执行链完整地找回来。

登录后查看全文
Hy-Memory