日志与可观测性
ZeroClaw 发出的每个事件都经过同一个 crate:zeroclaw-log。该 crate 负责磁盘上的 JSONL 架构、仪表板读取的进程内广播流、连接到类型化 Observer(Prometheus / OTel)的可选桥接,以及各子系统调用的宏(record!、scope!、spawn!)。
本页介绍运维人员所需的内容:配置、日志位置、事件结构以及如何查询它们。
配置([observability])
默认值:log_persistence = "rolling"、log_persistence_max_entries = 200、log_tool_io = "redacted"、log_tool_io_truncate_bytes = 40960、log_llm_request_payload = "off"。全新安装会在 ~/.zeroclaw/data/state/runtime-trace.jsonl 生成一个包含 200 条事件的滚动 JSONL,仪表盘的 Logs 页面无需进一步配置即可正常工作。
log_persistence = "none" 会完全禁用持久化,但不会限制 dashboard SSE 使用的广播流。可选的类型化 Observer 桥接同样独立于持久化,但只有在显式绑定后才会接收规范日志事件;当前的生产启动流程不会安装此绑定。
持久化采用尽力而为的方式,而非提供事务性审计保证。Observer 桥接(如果已绑定)和广播传递会在事件提交到有界后台写入器队列之前完成。队列已满或工作线程写入失败,可能导致事件未写入 JSONL。定期同步会覆盖当前活动文件;每日轮换会在新的 UTC 日期首次追加之前进行,大小轮换会在追加导致超出阈值之后进行,这两种轮换都可能在首次同步活动文件之前重命名该文件,因此对于刚刚轮换出的归档文件,该同步节奏无法界定其持久性。有关不同的传递契约,请参阅日志记录架构。
归档轮转 (log_persistence = "rotating")
rotating 不会像 full 一样对后台写入器接收的事件按条目数进行裁剪,但 ZeroClaw 会管理活动文件:达到大小限制和/或每日边界时,活动文件会轮换为带时间戳的归档文件,并按数量和存留时间清理旧归档。这与 rolling 不同,后者会从活动文件中裁剪旧条目;轮换的事件会保留在归档文件中,以便后续诊断。
| 键 | 默认 | 效果 |
|---|---|---|
log_persistence_max_bytes | 0 | 当追加写入使活动文件达到或超过此字节数时旋转一次。0 禁用按大小轮转。 |
log_persistence_rotate_daily | true | 在新的 UTC 日的第一个事件之前,归档一个最后写入发生在更早一天的文件。 |
log_persistence_retention_max_files | 7 | 最多保留这么多归档;轮转后,超出上限的最旧归档会被删除。0 表示保留全部。 |
log_persistence_retention_max_age_days | 0 | 轮换后删除早于这么多天的归档。0 会禁用基于年龄的清理。 |
归档文件与活动文件并排放置,并保留其扩展名,在该扩展名前插入一个可排序的 UTC 时间戳。例如,runtime-trace.jsonl 会轮转为 runtime-trace.20260624-031500.jsonl。仪表板和 /api/logs 端点只读取活动文件,因此归档文件只是供离线检查的磁盘记录,而不是实时查询入口。
每日轮换密钥以 UTC 日历为准,因此其边界可能不会与其他时区的本地午夜对齐。除非 log_persistence = "rotating",否则这些密钥会被忽略,而 none、rolling 和 full 模式保持不变。
GenAI span 属性(observability-otel)
llm.response span 携带 OTel GenAI 消息内容属性 gen_ai.input.messages、gen_ai.output.messages 和 gen_ai.system_instructions(以 JSON 字符串编码),它们会填充 Langfuse/Tempo 中的输入/输出/系统面板。
隐私与成本。 捕获的内容会尽力进行清理:内联图像数据会被省略,已知的凭据格式(key=value、bearer,以及
sk-/ghp_/xoxb--style 前缀)会被遮盖。这并不保证移除所有机密信息或 PII。如果对话可能包含敏感内容,建议使用受访问控制的 trace 后端。捕获成本为每个 agent-loop 迭代的 O(prompt size)(不断增长的历史会在每轮重新扫描),且完整文本会按比例随每个 span 的 payload 增长。对于按字节计费的后端,应在 exporter 端进行截断,而不是丢弃这些属性。
OTel 内容捕获
OTel 内容捕获独立于基于日志的捕获(log_tool_io, log_llm_request_payload)。它控制哪些内容会作为 OpenTelemetry span 属性发出。
GenAI 内容
在 OTel spans 上控制 gen_ai.system_instructions、gen_ai.input.messages 和 gen_ai.output.messages。
[observability]
otel_genai_content = "off" # off | redacted | full
otel_genai_content_max_chars = 1000 # 每个字段的截断限制
off(默认):无内容属性,仅元数据。redacted:内容经过泄漏扫描,并在每个字段按max_chars截断。full:内容已进行泄漏扫描,但未截断。
工具 I/O
在 OTel spans 上控制 gen_ai.tool.arguments、input.value、gen_ai.tool.result 和 output.value。
[observability]
otel_tool_io = "off" # off | redacted | full
otel_tool_io_max_chars = 1000 # 每个字段的截断限制
off(默认):不包含内容属性,仅包含工具名称 + 结果。redacted:内容经过泄漏扫描,并在每个字段按max_chars截断。full:内容已进行泄漏扫描,但未截断。
行为说明
- 将
*_max_chars = 0设置为等同于该策略的off。 - 内容在截断前始终会被清理(凭据模式 + 密钥模式)。
- 截断会保留工具参数的 JSON 结构(叶子字符串会被截断)。
- 截断的字段会带有
…[truncated {n} of {total} chars]标记。该标记属于元数据,不计入max_chars:保留的内容恰好是max_chars个字符,并在其后附加该标记。 - 默认
off是相较于先前行为的一项以隐私优先的变更(功能受特性门控,但在启用时始终开启)。 - 内容策略绑定到 observer/config 实例,而不是进程。不存在进程全局的 OTel 内容策略:每个
OtelObserver在构造时都会从ObservabilityConfig派生一个不可变的内容配置,并在 OTel 导出边界处查阅它。同一进程中的多个 observer 保持独立的策略:后创建的 observer 不能覆盖或静默关闭先创建的 observer 的隐私设置(没有最后写入者生效,也没有跨 observer 漂移)。
嵌套内存和 RAG span(observability-otel)
memory.recall、memory.store 和 rag.retrieve span 会在操作于已归属的 agent turn 内运行时嵌套在 gen_ai.agent.invoke turn span 下,因此完整的 turn(记忆召回、自动保存存储、LLM 调用、工具调用)会在 Langfuse/Tempo 中呈现为一条 trace。这三个事件携带与 LLM 和工具事件相同的 channel / agent_alias / turn_id 三元组,并作为 zeroclaw.channel、gen_ai.agent.name 和 zeroclaw.turn_id span 属性公开。
在关联的 turn 之外进行的内存操作会持续产生根 span:网关 REST 内存存储,以及 process_message 硬件 RAG 检索——后者在 turn 括号开启之前运行,因此仍是一个带有匹配 zeroclaw.turn_id 属性的根 span(该 span 的完整嵌套在 #8844 中跟踪)。不再匹配活跃 turn 的 turn_id 也会降级为根 span,而不是猜测其父级。
LLM 请求载荷捕获 (log_llm_request_payload)
log_llm_request_payload 控制 llm_request 事件是否在 messages_count 之外还记录发出的提示词和对话。它默认关闭,且是一个隐私敏感面:启用后,ZeroClaw 会在每一轮持久化完整的系统提示词以及整个对话历史。
| 值 | 捕获了什么 |
|---|---|
off(默认) | 仅 messages_count。不记录消息内容;现有行为。 |
redacted | 完整消息历史记录(角色 + 内容),使用与 raw_response 和工具 I/O 相同的 scrub_credentials 处理后,再按 log_tool_io_truncate_bytes 截断。截断会通过 request_messages_truncated 和 request_messages_original_bytes 标记。 |
full | 与 redacted 相同的凭证清理,但不截断(回放保真度,镜像 raw_response)。 |
redacted 和 full 都始终执行凭据清理;它们之间唯一的区别是截断。该捕获复用了现有的 log_tool_io_truncate_bytes 上限,而不是引入第二个上限。将 log_llm_request_payload = "off" 设为该值,或保持其为该值,即可立即禁用捕获,无需重新部署。
磁盘存储格式
JSONL:每行一个事件,UTF-8,在 Unix 上权限为 0o600。热路径是非阻塞的:record_event 通过有界通道将序列化后的事件交给专用后台线程(zeroclaw-log-writer),然后立即返回。工作线程按固定周期调用 sync_all:每写入 100 次或每经过 1 秒的墙钟时间,以先到者为准,另外在正常关闭时通道关闭后还会进行一次最终的 sync_all。这用有界写入延迟换取了逐事件持久性(之前的同步行为):进程崩溃时,最多可能丢失一个同步间隔内尚未写入的数据。如果工作线程落后,record_event 会丢弃该事件并通过 tracing::warn! 发出警告,而不是阻塞 async 运行时。工作线程是每进程单例;通过 init_from_config 禁用并重新启用持久化时,会丢弃旧工作线程(通道关闭会触发其最终同步和线程退出),并启动一个新的工作线程。
行结构镜像 zeroclaw_log::event::LogEvent。顶层键:
| 键 | 类型 | 备注 |
|---|---|---|
id | UUID v4 字符串 | 持久化事件 ID。 |
@timestamp | RFC 3339 + 毫秒,UTC | 按字典序可排序;读取器会基于此进行排序。 |
severity_number | u8 | OTel:1 TRACE、5 DEBUG、9 INFO、13 WARN、17 ERROR。 |
severity_text | 字符串 | severity_number 的桶标签。 |
event.category | 字符串 | agent、channel、cron、memory、tool、provider、session、system 或 internal。 |
event.action | 字符串 | 稳定标识符(llm_request、channel_message_inbound 等)。 |
event.outcome | string | omitted | success、failure、unknown(当为 unknown 时省略)。 |
service.name | 字符串 | Constant "zeroclaw". |
service.version | 字符串 | 正在运行的守护进程的 Crate 版本。 |
trace_id | 十六进制字符串 | 省略 | 每轮关联。一个智能体轮次 = 一个 trace_id。 |
span_id | 十六进制字符串 | 省略 | 回合内的子区间。 |
zeroclaw.* | 扁平字符串映射 | 别名绑定归因(见下文)。 |
message | string | omitted | 可读的行内容。 |
attributes | object | omitted | 自由格式的按操作负载。 |
schema_version | u8 | 当前为 2。v1 行在启动时就地迁移。 |
zeroclaw.* 归属
Rust 的事实来源是 crates/zeroclaw-log/src/event.rs 中的 ATTRIBUTION_FIELDS + COMPOSITE_PREFIXES。/api/logs 响应以 attribution_keys 形式携带规范列表;请获取它,而不要硬编码。
普通字段(ATTRIBUTION_FIELDS)各自携带一个字符串。复合前缀会获得三个键:<prefix>、<prefix>_type、<prefix>_alias(例如 channel = "discord.glados"、channel_type = "discord"、channel_alias = "glados")。过滤器可以进行粗略匹配或精确匹配。
当某个跟踪调用将复合前缀字段设置为裸类型(不含 .)时,仅填充 _type 槽位,这样一来,在已携带完整 <type>.<alias> 复合值的 span 内部调用 tracing::*!(model_provider = name, …) 时,就不会在叶→根合并过程中覆盖它。
查询中
仪表板的日志页面是主要界面。在其下方:
GET /api/logs
顶层过滤器(查询参数):since_ts、until_ts、until_line_offset、action、category、outcome、severity_min、trace_id、q(在 message 和 attributes 中进行子字符串匹配)、hide_internal(过滤掉 event.category = "internal" 的事件)、limit。旧版字段 until_id 仍可用于时间戳/ID 游标兼容性。
每个其他 ?<key>=<value> 都被视为单个归因等值过滤器,网关会根据 is_attribution_field 验证键,并以 400 拒绝未知键。响应中包含 attribution_keys: string[],因此调用方无需猜测。
示例:
sh
# 自守护进程启动以来的所有 WARN+ 级别事件。
curl "$ZEROCLAW_GATEWAY/api/logs?severity_min=13"
# 特定代理的事件:
curl "$ZEROCLAW_GATEWAY/api/logs?agent_alias=glados"
# 单个机器人的 Discord 流量:
curl "$ZEROCLAW_GATEWAY/api/logs?channel=discord.glados"
# 单个智能体回合:
curl "$ZEROCLAW_GATEWAY/api/logs?trace_id=<value-from-a-prior-event>"
日志分页使用字节偏移游标向后遍历。当 at_end 为 false 时,将非空的 next_cursor_line_offset 作为 until_line_offset 传回,并保持相同的非游标过滤条件,以加载更早的事件而无需重新读取较新的字节。更改过滤条件后,请从最新页重新开始。将 at_end: true 视为停止请求该分页遍历中更早页的信号。旧版 next_cursor: [timestamp, id] | null 响应保留以兼容性为目的;将其时间戳/ID 对用作 until_ts 和 until_id 进行分页的方式已弃用,因为基于字典序的 ID 决胜规则可能在时间戳相同时静默跳过事件。
until_line_offset 是当前活动文件中的位置,而不是持久化的事件检查点。纯追加会保留它,但滚动裁剪、归档轮换、启动迁移以及配置的路径更改会替换它所指向的字节内容或活动文件。越过这些边界后,应从最新页面重新开始,而不是重复使用较旧的偏移量。/api/logs 仅读取活动文件;需要较早的轮换历史记录时,请直接检查带时间戳的归档文件。
/api/status 响应中包含 daemon_started_at: string(RFC 3339),因此仪表板无需额外往返请求即可默认显示“自守护进程启动以来“的状态。
外部日志查看器
JSONL 模式是一种 OTel-logs + ECS 混合格式:@timestamp、severity_number + severity_text、event.{category,action,outcome}、service.{name,version}、attributes,外加 zeroclaw.* 厂商命名空间。大多数日志查看器无需转换或仅需少量转换即可读取它。请在下方示例中将 <install> 替换为你的安装目录的绝对路径(通常为展开后的 ~/.zeroclaw)。
Grafana Loki
Promtail 标签会提取 agent_alias、channel 和 severity_text,以便在 Grafana 中进行筛选:
scrape_configs:
- 任务名称: zeroclaw
静态配置:
- 目标: [localhost]
标签:
工作: zeroclaw
__path__: /data/state/runtime-trace.jsonl
pipeline_stages:
- json:
表达式:
代理: zeroclaw.agent_alias
频道: zeroclaw.channel
级别: severity_text
- 标签:
代理:
频道:
级别:
- 时间戳:
源: '@timestamp'
格式: RFC3339
OpenTelemetry Collector
filelog 接收器直接映射该 schema。之后可导出到任意 OTel 接收端(Tempo、Honeycomb、Datadog 等):
接收者:
filelog/zeroclaw:
include: [/data/state/runtime-trace.jsonl]
操作符:
- 类型: json_parser
时间戳:
parse_from: attributes["@timestamp"]
layout: '%Y-%m-%dT%H:%M:%S.%LZ'
严重程度:
parse_from: attributes.severity_number
Kibana / Elastic
数据采集可直接使用。严格的 ECS 管道要求使用 log.level 替代 severity_text。通过 Filebeat 采集管道将 severity_text 重命名为 log.level(并将 severity_number 重命名为 log.syslog.severity.code)即可弥补这一差异。@timestamp 和 event.{category,action,outcome} 已处于规范位置。
Vector / Fluent Bit
两者都使用 JSON 解析器阶段对 JSONL 进行 tail 操作;在发送到任何后端之前无需进行 schema 转换。
终端格式
守护进程的 stderr 格式化器会在每一行前添加最接近的、绑定了别名的标识:
- 代理上下文 →
[<agent_alias>] - 仅频道上下文(频道监听器,尚无代理)→
[<channel_composite>](例如[discord.glados]) - 否则 →
[system]
跨度链如下:channel_listener{channel=discord.glados}: …。跨度字段内联可见。
架构迁移
启动时,如果启用了 log_persistence 且文件已存在,写入器会在首次追加之前,将所有 schema-1 行通过就地迁移流式转换为 schema-2。这是纯流式处理,无论文件大小如何,内存占用都限定在单行的分配范围内。迁移后的文件会通过原子重命名就位。已是 v2 的文件将保持不变。
如果迁移失败,守护进程会记录一条 warn 日志并继续写入 v2 追加数据;旧的 v1 行仍可被理解 v1 的工具读取,但无法通过 v2 读取器的反序列化器。
什么是 internal?
event.category = "internal" 用于归集运维噪声,即操作员默认无需在仪表板上看到的事件:心跳信号、空闲广播、有损同步重试等。仪表板的“隐藏内部事件“开关(默认开启)会过滤掉这些事件。
当某个高频事件的出现对取证分析很重要,但其缺失才是正常状态时,使用它。不要将它用作真正错误的流量调节器。
相关文件
crates/zeroclaw-log/src/event.rs:规范的LogEvent结构。crates/zeroclaw-log/src/layer.rs:用于捕获每个tracing::*调用并将其馈送到流水线的tracing-subscriberLayer。crates/zeroclaw-log/src/macro.rs:record!、scope!、spawn!。crates/zeroclaw-log/src/writer.rs:追加、滚动修剪和归档轮转。crates/zeroclaw-log/src/reader.rs:/api/logs读取器。crates/zeroclaw-log/src/config.rs:StoragePolicy、ToolIoPolicy、ResolvedPolicy。crates/zeroclaw-log/src/migrate.rs:schema-1 → schema-2 流式迁移。crates/zeroclaw-log/src/observer_bridge.rs:面向 Prometheus / OTel 使用方的类型化Observer投影。crates/zeroclaw-gateway/src/api_logs.rs:HTTP 适配器。
在信任本页文字之前,请先查阅源代码。