日志记录架构
ZeroClaw 只有一个日志记录接口:zeroclaw_log::record! 宏。工作区中的每一次输出——智能体循环活动、通道 I/O、cron 运行、工具调用、内存操作、会话生命周期、错误——都通过它进行。该宏会触发一个 tracing 事件,已安装的订阅者将其传递给两个并列的层:stderr fmt 层(终端输出)和 LogCaptureLayer。fmt 层在 stderr 上打印带颜色、带别名前缀的行(除非使用 --verbose,否则会被静默)。LogCaptureLayer 会生成一个结构化的 LogEvent 并将其分发,经由 writer::record_event,发送到:
- 可选的 Observer 桥接(
observer_bridge::forward)用于映射到类型化 Prometheus / OTel 事件的那部分操作,但仅在调用方使用set_observer_bridge安装了绑定时才启用。当前的生产引导不会安装该桥接。 - 可选的原生 OTLP 日志导出器(
export_bridge加运行时otel_logs),在observability-otel构建中当backend = "otel"时运行。 - 面向整个进程的广播通道,用于实时订阅者,例如仪表板的 SSE 流。
- 用于
<workspace>/state/runtime-trace.jsonl的异步 JSONL 写入器(当[observability] log_persistence为"rolling"、"full"或"rotating"时)。
When configured, the Observer projection, OTLP queue, and broadcast send happen before the persistence enqueue attempt. Those four destinations have different completeness and durability guarantees; sharing one LogEvent does not make them interchangeable.
Wait, I need to correct myself: the source is English and needs translating, so here is the translation.
当配置启用时,Observer 投影、OTLP 队列和广播发送会在持久化入队尝试之前执行。这四个目标在完整性和持久性保证方面各不相同;共享同一个 LogEvent 并不意味着它们可以互换。
请先阅读:attribution 不是 attrs
每个日志事件都携带两条完全独立的结构化数据通道。混淆它们是调用点处最常见的错误,因此请在做其他事情之前先牢记这种区分:
归属信息(zeroclaw.*) | Attrs (attributes.*) | |
|---|---|---|
| 答案 | 谁 做的以及 在什么情况下 | 具体发生了什么 |
| 示例 | channel、agent_alias、model_provider、tool、session_key、cron_job_id | bytes_received、tokens_used、status_code、错误负载 |
| 源 | Span(跨度)。 在入口点处打开,由该层遍历。 | 调用点。 Event::with_attrs(json!({...}))。 |
| 出现在调用位置? | 永不。 不是 record! 参数。 | 是的,这是它唯一可能的来源。 |
由此得出的规则:如果某个值标识了一个事件归属于谁或哪个作用域,那么它应当来自 span,而绝不能出现在调用点。 归属信息会从调用栈上层打开的 attribution_span! / scope! 包装器中自动流入;当事件触发时,该层会从叶到根遍历 span 作用域,并将每一项贡献合并到该事件的 zeroclaw.* 块中。触发 record! 的调用点不会指明其中任何内容。
由于归因是这种拆分中起承重作用的那一半,也是最容易让人栽跟头的那一半,所以它排在最前面。
归因:一切都源自 span
归因属性从来不是调用点参数。 请再读一遍。Channel composite、agent_alias、model_provider、tool、session_key、cron_job_id:这些都不应被键入到 record! 调用中。它们通过在入口点开启的 tracing span 流入,并在事件触发时由该层遍历获取。如果你发现自己想把 agent_alias 或 tool 传给 record!,请停下:该值已经通过某个 span 处于作用域内,或者本应如此,正确的做法是开启或修复对应的 span,而不是把这个值穿入调用点。
机制全流程:
- “事物”(通道、提供方、代理、工具、定时任务、内存后端……)在其结构体旁边实现一次
Attributable即可。 - 其入口点将工作封装在
attribution_span!(self)中,这会打开一个追踪 span,携带该事物的角色和别名。 - 在该 span 内任何位置触发的每个
record!,无论是直接触发还是任意深度的嵌套触发,都会自动继承该归属信息。 - 当事件触发时,该层会按 leaf→root(叶到根)遍历 span 作用域,合并每个
Attributable的贡献,并写入合并后的zeroclaw.*块。调用方对此一无所知。
这正是该设计的核心要义:针对每个事物的日志代码为零。你只需实现一次该 trait 并对入口点进行一次包装;其下的每一次输出都会被自动归因,无需额外开销。
在继承归属信息时,复合分组会归属于最近的一个 span,前提是该 span 的已存储归属信息包含以下任一键:<prefix>、<prefix>_type 或 <prefix>_alias。除非内层分组只包含 <prefix>_type,且外层分组的类型相同,否则该分组中缺失的成员不会从外层 span 继承。例如,内层 scope!(channel: "telegram", ...) 会保留外层的 channel = "telegram.outer" 和 channel_alias = "outer";而内层 scope!(channel: "webhook", ...) 在该外层 Telegram span 下仍仅保留类型信息。显式的内层复合分组(如 telegram.inner)即使外层类型相同,也会保留其自身实例。普通归属字段和无关的复合分组仍按常规方式继承。同一个 span 内延迟记录的字段会继续填充该 span 中缺失的字段。
Attributable trait
位于 crates/zeroclaw-api/src/attribution.rs 中,因此每个 crate 都可以实现它,而无需依赖 zeroclaw-log:
#![allow(unused)]
fn main() {
pub trait Attributable {
fn role(&self) -> Role;
fn alias(&self) -> &str;
}
}
工作区中的每个“事物”(一个 TelegramChannel、一个 AnthropicModelProvider、一个 Agent、一个 cron 作业、一个工具、一个内存后端、一个对等组、一个技能包、一个 MCP 包、一个会话)都在其结构体旁实现了一次 Attributable。
Role 分类法
嵌套枚举已关闭:
#![allow(unused)]
fn main() {
pub enum Role {
Swarm,
Agent,
Channel(ChannelKind), // Telegram、Discord、Slack、Matrix、Lark……
Tool(ToolKind), // Shell、HttpRequest、FetchUrl 等……
Cron(CronKind), // 间隔、定时、Cron、单次
Provider(ProviderKind), // 模型、Tts、转录、隧道
Memory(MemoryKind), // Sqlite、Json、InMemory、Markdown、Qdrant 等……
PeerGroup,
Skill,
Mcp,
Sop,
Session,
System,
}
}
ChannelKind、ToolKind、CronKind、MemoryKind 以及四个 ProviderKind 子枚举(ModelProviderKind、TtsProviderKind、TranscriptionProviderKind、TunnelProviderKind)都是封闭的。通过 strum::IntoStaticStr 得到的变体 snake_case 形式即为 <type>.<alias> 组合中规范的 <type> 部分。添加新实现:扩展相关的 Kind 枚举,仅此而已。
打开一个跨度,在每个入口点执行此操作
使用 attribution_span!(thing) 包裹入口点的工作。该宏返回一个 Span,以结构化字段携带该 thing 的角色和别名。对 future 调用 .instrument(span)(或在同步代码中使用 let _g = span.entered())。未重新建立 span 的派生任务会丢失归属信息:每个会发出数据的 tokio::spawn 主体都必须携带与父级相同的 attribution_span! / scope!,否则其发出的数据将无法归属。
#![allow(unused)]
fn main() {
use zeroclaw_log::Instrument;
let span = zeroclaw_log::attribution_span!(self); // self 实现了 Attributable
async move {
// 每条记录!内部自动携带别名绑定的字段
record!(INFO, Event::new(module_path!(), Action::Start), 频道在线);
self.poll_loop().await
}.instrument(span).await
}
当事件触发时,该层会沿着 span 作用域从叶节点到根节点遍历,将每个 Attributable 的贡献合并到事件的 zeroclaw.* 归因块中,并发出复合属性(channel = "telegram.clamps"、channel_type = "telegram"、channel_alias = "clamps"),而调用方无需指定其中任何键。
scope! 宏,非角色上下文
attribution_span! 用于带角色的 Attributable 对象。对于不与某个对象绑定的按作用域标识符(发送者 id、消息 id、回合 id、请求 id),请使用 scope!:
#![allow(unused)]
fn main() {
zeroclaw_log::scope!(
sender: msg.sender.as_str(),
message_id: msg.id.as_str(),
=> async move { process_message(msg).await }
).await
}
SOP 步骤执行使用相同的机制,关联键为 sop_run_id。与单个回合的 trace_id 不同,此归因在运行中所有嵌套的步骤回合中保持稳定,因此网关、CLI 和 TUI 均可查询同一个持久关联键。
scope! 刻意横跨 attribution/attrs 这条界线:凡是匹配别名绑定的 ATTRIBUTION_FIELDS / COMPOSITE_PREFIXES(位于 crates/zeroclaw-log/src/event.rs)的字段键,都会进入有类型的 zeroclaw.* 归因槽;其余字段则进入事件的 attributes 映射,应用于每一次后代发射。无论哪种情况,该值都会随每一个嵌套的 record! 一同传递,而无需作为调用点参数。
record! 宏及其调用点约定
tracing crate 是 zeroclaw-log 的实现细节:record! / scope! / attribution_span! 宏会展开为 zeroclaw_log::__private::tracing,因此调用点永远不会直接引用 tracing 类型。日志事件宏本身(tracing::{trace,debug,info,warn,error}、log::*、std::dbg,以及裸用的 anyhow::anyhow!)在 clippy.toml 中被作为 disallowed-macros 在整个 workspace 范围内硬性禁用。由于 CI 中启用了 -D warnings,任何直接使用 tracing::info! 等的代码都会导致构建失败,并附带一条 clippy 消息,指明应改用 ::zeroclaw_log::record!。这并非约定,而是强制执行的规则。
唯一的例外是 crates/zeroclaw-log/ 内少数用于引导流水线的文件,它们带有本地的 #![allow(clippy::disallowed_macros)]。少数几个 crate(zeroclaw-api、zeroclaw-spawn、zeroclaw-providers、zeroclaw-hardware、zeroclaw-log)仍在 Cargo.toml 中列出了 tracing / tracing-subscriber,但仅用于 span 和 subscriber 的底层连接,而非用于发出日志宏。依赖项的存在并不意味着可以调用被禁用的宏。(tokio::spawn 同样通过 disallowed-methods 被禁用;请使用 ::zeroclaw_spawn::spawn!,以便派生的任务继承调用方的归因 span。)
宏是锁定形态的:它接受一个级别、一个 Event 表达式和一个消息字面量。
#![allow(unused)]
fn main() {
use zeroclaw_log::{record, Event, Action, EventCategory, EventOutcome};
record!(INFO, Event::new(module_path!(), Action::Start), 启动步骤);
record!(WARN, Event::new(module_path!(), Action::Fail).with_outcome(EventOutcome::Failure).with_attrs(serde_json::json!({"exit_code": 137})), 工具失败);
}
module_path!() 是事件名称的规范来源:它是调用点的 Rust 模块路径(例如 zeroclaw_channels::telegram),因此事件可被搜索、可跳转到源代码,且不可能拼写错误。工作区中的每个 record! 调用点都使用相同的约定。
宏会自动注入 file!() 和 line!()。LogCaptureLayer 将它们作为 _file 和 _line 附加到事件的 attributes 映射中,以便操作人员从日志查看器跳转到源代码。
调用点约定
每次 record! 调用都是一行代码,用于说明发生了什么,而不是谁做的或在什么上下文下做的。
- 级别后的单个位置参数是一个
Event表达式。 - 下一个参数是用于人类可读消息的字符串字面量。
- 以上就是全部内容。Channel、agent_alias、provider、tool、session_key、cron_job_id、model:这些都不是调用点参数。它们都从 span 中流入(参见 Attribution: it all comes from spans)。
形状由 Event 结构体强制约束:未知字段会导致编译错误。
需要属性时
Event::with_attrs(serde_json::json!({...})) 用于单个事件的测量值,以及周围作用域中不存在的临时数据。具体而言:
- 每事件度量:
bytes_received、tokens_used、retry_count、status_code、queue_depth。 - 当错误本身就是事件时的错误负载:anyhow 链式文本、HTTP 错误正文、解析错误详情。
- 外部系统标识符:远程 API 的
request_id、上游跟踪标头。 - 此刻捕获的派生状态:进行中的请求数、retry-after 秒数。
Attrs 不适用于任何来自周围作用域的内容:channel composite、agent_alias、model_provider、tool、session_key、cron_job_id、sender、message_id 等。这些应放在外层的 attribution_span! 或 scope! 中。
serde 规则:传递原始值,绝不要使用 format!("{}", v) 或 format!("{:?}", v)。serde_json::json! 会将字符串序列化为字符串,数字序列化为数字,Vec<T> 序列化为数组,Option<T> 序列化为 null 或值。仅当类型未实现 impl Serialize 时(例如 anyhow::Error、reqwest::Error、std::io::Error、Path::Display、StatusCode)才使用 .to_string() 包装。
占位符规则
Rust 字符串字面量占位符(如 "raw error body: {body}")禁止在 record! 消息中使用。Rust 2021 的隐式格式字符串捕获不会穿透 record!:每个 {var} 都会变成字面子串,不进行替换。转换规则:
#![allow(unused)]
fn main() {
// 错误 — {body} 是字面量,绝不会被插值
record!(WARN, Event::new(module_path!(), Action::Fail), raw error body: {body});
// 正确 —— 正文位于 attrs 中,message 为纯文本说明
record!(WARN, Event::new(module_path!(), Action::Fail).with_attrs(serde_json::json!({"body": body})), 原始错误正文);
}
Event、Action、EventOutcome、EventCategory
这四个都是定义在 crates/zeroclaw-log/src/event.rs 中的封闭枚举。添加值是唯一的更改点:调用点不会自行创建字符串。
Action:封闭动词集合,通过strum::IntoStaticStr以 snake_case 形式存储于磁盘:Start、Complete、Fail、Cancel、Skip、Timeout、Retry、Inbound、Outbound、Send、Receive、Connect、Disconnect、Reconnect、Spawn、Kill、Tick、Trigger、Schedule、Approve、Reject、Defer、Read、Write、Delete、List、Query、Invoke、Dispatch、Resolve、Register、Unregister、Load、Save、Migrate、Validate、Note。EventOutcome:Success、Failure、Unknown。Unknown是默认值,在序列化时会被跳过(不会写入磁盘上的event.outcome),因此没有outcome键的行隐式表示Unknown。EventCategory:Agent、Channel、Cron、Memory、Tool、Provider、Session、System、Internal。除非通过Event::with_category(...)覆盖,否则派生自最内层的角色范围。
工具输入/输出传递
中央工具执行器(crates/zeroclaw-runtime/src/agent/tool_execution.rs::execute_one_tool)会用 invoke/complete/fail 事件包裹每一次 Tool::execute(args) 调用。每个事件的名称是 module_path!()(即执行器自身的模块),而非硬编码字符串;通过 Action 和严重程度来区分它们:
- 在运行之前:
record!(DEBUG, Event::new(module_path!(), Action::Invoke).with_category(EventCategory::Tool).with_attrs(...)),并在 attrs 中包含tool、tool_call_id以及完整的input。 - 运行
execute(args).await。 - 成功时(
r.success):调用record!(DEBUG, ... Action::Complete),并在 attrs 中记录Outcome::Success、持续时间以及tool/tool_call_id/input/output。 - 工具报告失败时(
!r.success):使用record!(WARN, ... Action::Fail),并在 attrs 中记录Outcome::Failure、持续时间以及tool/tool_call_id/input/error/output。 - 在
execute返回Err时:使用Outcome::Failure、持续时间以及 attrs 中以 debug 格式化的错误调用record!(ERROR, ... Action::Fail)。
这些事件是在围绕调用打开的 scope! 风格的 span 内发出的(target = "zeroclaw_log_internal_scope",字段 tool = <name>),因此 tool 字段也会随每个后代发出项一同传递。各工具的 Tool::execute 实现无需添加任何日志代码。
LogCaptureLayer 与磁盘存储架构
crates/zeroclaw-log/src/layer.rs 中的 layer 是一个 tracing-subscriber Layer,它:
- 在创建/记录目标为
"zeroclaw_log_internal_attribution"的 span 时(即attribution_span!宏所开启的目标):将 role 和 alias 字段解析为ZeroclawAttribution快照,并存储在该 span 的 extensions 中。 - 在以目标
"zeroclaw_log_internal_scope"创建/记录 span 时(由scope!打开):解析临时 kvp 并以类似方式暂存它们。 - 在事件发出时,目标为
"zeroclaw_log_event"(record!宏触发所经过的目标):从zc_*字段集构建一个LogEvent,从叶节点到根节点遍历 span 作用域并合并找到的每个归因快照,将zc_attrsJSON 数据块解析为事件的attributes,附加来自自动捕获的源代码位置的_file/_line,然后将最终事件交给writer::record_event,它按以下顺序进行分发:- 绑定 Observer 时用于映射 Prometheus / OTel 类型化事件的 Observer 桥接器(
observer_bridge.rs)。 - 为每个规范事件提供原生结构化日志桥接(
export_bridge.rs)(前提是已安装导出器);observability-otel运行时会将其映射为 SDK LogRecord,并排队进行 OTLP/HTTP protobuf 导出。 - 安装发送器时,面向当前 SSE/仪表板订阅者的广播钩子(
broadcast.rs)。 - JSONL 持久化(
writer.rs),仅在启用log_persistence时最后才发送到异步写入器队列。
- 绑定 Observer 时用于映射 Prometheus / OTel 类型化事件的 Observer 桥接器(
磁盘上的 JSON 结构(event.rs 中的 LogEvent):
{
"id": <uuid>,
@timestamp: 2026-05-16T10:08:59.002Z,
"severity_number": 9,
"severity_text": INFO,
事件: { category: channel, action: inbound, 结果: 成功 },
service: { "name": zeroclaw, version: "0.8.6" },
trace_id: <turn id>,
span_id: <sub-span id>,
zeroclaw: {
channel: telegram.clamps,
"channel_type": Telegram,
channel_alias: "clamps",
agent_alias: "clamps",
"model_provider": anthropic.clamps,
"model_provider_type": anthropic,
model_provider_alias: "clamps",
"模型": claude-sonnet-4-6
},
"消息": 入站消息,
attributes: { sender: "...", _file: "...", _line: 42 },
schema_version: 2
}
@timestamp 是 chrono::DateTime<Utc>,序列化为带 Z 的 RFC 3339 格式。架构版本为 2;较旧的 version: 1 行会在守护进程启动时由 migrate::migrate_legacy_jsonl_in_place 就地迁移。
不同的交付渠道具有不同的保障
writer::record_event 会构建一次持久化值,然后从同一个 LogEvent 派生其他投递内容。每个目标都有独立的契约:
| 目的地 | 所有者 | 合同与损失边界 |
|---|---|---|
| 可选的类型化 Observer 桥接 | observer_bridge.rs | forward 在显式绑定 Observer 之前是空操作,而当前的生产环境启动流程并未绑定 Observer。绑定后,它会同步转发,但只投影 project 所识别的操作;当前映射可能会遗漏某些操作或默认字段。应将其视为选择性指标/跟踪投影,而不是完整的事件日志,并检查 project 以了解当前的字段映射。 |
| 原生 OTLP 日志 | export_bridge.rs + observability/otel_logs.rs | 仅在带有 backend = "otel" 的 observability-otel 构建中存在。每个规范事件都会同步交给 OpenTelemetry SDK 的非阻塞批处理队列,然后以 OTLP/HTTP protobuf 格式导出。队列或导出失败不会阻塞或禁用 JSONL/广播。重新配置和 Observer 刷新会请求批量刷新,但进程异常终止时,队列中的记录仍可能丢失。临时广播属性永远不会到达此桥接器。 |
| 直播 | broadcast.rs 及其消费者 | 将结构化事件发送给当前进程内的订阅者。订阅者只能看到其订阅后发出的事件,有界接收器可能会滞后,网关 SSE 适配器会跳过滞后的帧。仅用于广播的临时属性可能出现在经过身份验证的实时帧中,但不会包含在持久化的 JSONL 中。这是一条实时通知路径,而不是可重放的证据。 |
| 持久化 JSONL | writer.rs | 在不阻塞运行时的情况下,将序列化事件入队。有界队列已满时可能丢弃事件,工作线程写入失败会作为警告处理,而周期性的 sync_all 仅作用于当前活动文件。新的 UTC 日期首次追加前执行的按日轮换,以及导致超过阈值的追加后执行的按大小轮换,都可能在未先同步活动文件的情况下将其重命名,因此,该同步周期无法保证刚刚轮换出的归档文件的持久性。随后,持久化模式决定是截断活动文件、无限期保留,还是进行轮换。这是尽力而为的操作历史记录,而不是事务性审计日志。 |
不要用 Observer、OTLP 或 SSE 投递来证明每个规范事件都已被保留。反过来,也不要假定 JSONL 中缺少某一行就说明它从未被发出:该事件可能在持久化队列丢弃或写入失败之前,已经送达 OTLP、实时广播,或(已绑定时)Observer 桥接。
Observer 事件共享同一条总线
类型化的观察者事件(agent_start、agent_end、llm_request、tool_call、tool_call_start、history_trimmed、error)通过一条独立路径到达同一个广播通道:zeroclaw_runtime::observability::broadcast。由 create_observer 构建的每个观察者都会分流到一个进程级的广播钩子中,而 EventBus(包含实时发送端和一个 500 帧的历史缓冲区)负责提供该钩子。
- 守护进程下,
daemon::run无论网关是否运行,都会创建总线并仅安装一次其钩子。因此,即使网关关闭,RPClogs/subscribe仍会携带这些帧。受监管的网关会复用守护进程的总线,且不安装任何内容,因此每个帧只会被投递一次、缓冲一次。 - 独立网关(
zeroclaw gateway start)会自行构建并安装其自己的总线。 - 历史记录:
GET /api/events/history以及 RPCevents/history(权限Logs:Read)会按从旧到新的顺序回放缓冲的观察者帧。与实时投递一样,它们从不携带配对凭据:带有临时标记的帧会从历史记录中被隐去,logs/subscribe也会将其从实时投递中隐去。
Log 层直接在通道上发送的帧仅用于实时传输;历史缓冲区仅保存观察者帧。
RPC 订阅可以重放,且绝不会静默结束
RPC 流并不直接读取广播通道。SubscriptionHub(zeroclaw_runtime::rpc::subscription)会将总线内容复制到有界环形缓冲区中,每个来源一个缓冲区,而每个订阅者只是环形缓冲区中的一个游标。目前存在两个来源:
| 方法 | Ring | 通知 |
|---|---|---|
logs/subscribe | 每个总线帧 | logs/event |
events/subscribe | 仅观察者帧 | events/event |
- **边界:**每个环形缓冲区最多保存 2,048 帧且不超过 4 MiB,所有环形缓冲区共享 16 MiB 的进程级预算。超出预算时,将淘汰所有环形缓冲区中最旧的一帧。
- 生产者从不等待订阅者; 慢速订阅者只会落后。
- 序列号: 每个来源的序列号从 1 开始计数。每条通知都包含
subscription_id及其seq。订阅结果会返回最新的seq和中心节点的epoch。 - 纪元: 每个 hub(每次守护进程启动或重新加载)都会获得一个新的随机
epoch,编号从 1 重新开始,因此序列号只在其所属纪元内有意义。 - 恢复:
X/subscribe{since_seq, epoch}会重放仍在缓冲区中、位于since_seq之后的帧,然后继续接收实时数据,但仅当epoch匹配时才生效。- With a different epoch, or none, the client gets
subscription/laggedwithepoch_changed: true, then every frame the new hub still buffers. - 同一 epoch 内,
since_seq超过最新帧的序号时,请求会被以INVALID_PARAMS拒绝。
- With a different epoch, or none, the client gets
- 间隙: 间隙必须被报告,绝不能被跳过。若游标指向的帧已不存在(被容量上限或预算驱逐,或在总线上溢出),则接收
subscription/lagged{subscription_id, from_seq, resume_seq},并从resume_seq继续。from_seq永远不大于resume_seq。重放批次在遇到间隙时停止,因此游标绝不会跨越已记录的丢失。 - 取消:
subscription/cancel{subscription_id}(授权Logs:Read,与订阅时相同)用于结束调用连接上的单个订阅。关闭连接会结束其所有订阅。 - 授权范围: 这些流和
events/history仅支持无作用域访问。其帧来自所有主体的工作内容(会话消息、定时任务结果、日志行),且许多帧未指明所有者,因此无法按主体过滤。调用方需要Logs:Read权限,并且必须是管理员或未认证的共享操作员,即绕过会话所有权检查的同一类主体。已认证的非管理员主体会被拒绝,即使使用*智能体选择器也不例外:面向所有智能体并不等同于拥有每个用户的会话。 - **复查:**每一次投递(无论是数据帧还是
lagged通知)都会根据调用方当前的凭据进行复查。当策略发生变化时,会重新解析主体;如果降级该主体或移除Logs:Read,流将被终止。 - 回放范围: 管理员或共享操作员可以回放其连接之前的帧,最多回放到环形缓冲区上限。
- 配对凭证在到达环之前就被丢弃,因此两个流都无法投递或重放它们。
Reader 游标跨越当前文件和保留的归档文件
GET /api/logs 和 logs/query 都会调用 reader::query_log_page,由它负责分段枚举。合并的内容取决于已安装的存储策略:只有 rotating 会管理归档文件,因此只有在 rotating 下,读取器才会将活动文件与保留的归档文件合并为一个逻辑事件流。在 rolling、full 和 none 下,即使旧的 rotating 配置遗留的带时间戳文件仍与活动文件并存,读取器也只读取活动文件;这些文件属于非受管文件,不属于该策略的逻辑流。分段按从旧归档到新归档的顺序扫描,合并结果按从新到旧的顺序返回,与之前的单文件语义保持一致。合并是顺序执行且在进程内完成的,不会重新序列化任何事件。
段的顺序取决于轮转时写入每个归档文件名中的序列号,而不是文件的修改时间(mtime)。若按修改时间排序,得到的只是枚举时的一次观察结果;如果在一次读取期间发生多次轮转,这个结果可能已不再反映真实顺序。在引入序列号编号之前写入的归档不带编号,它们按修改时间排序,并且排在所有带编号归档之前。
主要分页游标是 next_segment_cursor,它是一个不透明字符串。恢复读取时,请将其原样作为 until_segment_cursor 传回;不要构造或解析它。其内部结构取决于页面结束于哪种段,因为三种段类型具有不同的稳定性特征:
- 编号归档通过轮转时写入其文件名中的序号进行寻址。一个尽力而为的旁路文件(sidecar)会在正常重启之间保持序号单调递增。如果该文件写入失败、保留策略删除了所有编号归档,并且守护进程随后重启,则序号可能被重新使用,尚未完成的游标可能会绑定到更新的历史记录上。
- 遗留归档在引入序号机制之前编写,通过文件名进行寻址。归档名称永远不会被重新分配给不同的内容,因此这种寻址方式同样是稳定的。
- active file(活动文件)由偏移量加锚点事件 ID 进行寻址。其路径保持稳定,但每次轮转时内容都会被替换;正是锚点使读取方能够检测到轮转已经发生,并从锚点事件现在所在的归档文件中继续读取。
传统的字节偏移游标 next_cursor_line_offset / until_line_offset 仍然有效,且仅针对当前活动文件进行解析。当当前页面中最早的事件位于归档文件中时,该值为 None。对于多段部署,请改用 next_segment_cursor。
该偏移量不是持久的事件标识,也不是跨文件的检查点。当分段游标所指向的分段已因保留策略被清理时,该游标将无法解析。此时读取器会报告历史记录已结束,而不是从一个无关的位置继续读取,因此分页客户端会停在原处,而不会悄无声息地跳回到最新的事件。每当活动文件的字节内容被替换或其路径发生变化时,字节偏移游标就会失效:
rotating会将活动文件重命名为归档文件;下一次追加操作会创建新的活动文件。- 模式迁移会通过临时文件和原子重命名来重写活动文件。
- 重新加载守护进程配置可以应用新的持久化路径。
在这些边界之一之后,从最新的页面重新开始分页。旧版时间戳/ID 游标仍保留以兼容,但已根据 #8012 被弃用,因为按字典序的 ID 排序可能会跳过时间相同的事件。
枚举与读取并非原子操作,因此轮转可能恰好发生在两者之间:列表中记录的是轮转前的活动文件,随后重命名将该文件变为归档,而扫描时读取的却是新生成的替代文件。被轮转走的事件随之会夹在页面实际读取的两段数据之间而未被列出,由于分页只能向更早的历史方向遍历,这些事件将永远无法访问。读取器在读取完成后重新检查归档集合,若集合发生变化则重新获取该页。之所以要在读取之后而非之前检查,正是这一点堵住了竞争窗口:若在打开文件之前检查,则在检查与打开之间仍可能发生轮转。枚举过程本身也存在同样的竞争:先列出归档文件,再对活动路径执行 stat 操作,若轮转恰好发生在这两步之间,数据便会对两部分查询都不可见,因为重命名与下一次追加之间的真空期会使活动路径不存在。因此,只有当页面在已列出的 listed 活动文件之前就停止时,才会跳过重新检查;归档文件是不可变的,所以此类扫描不可能与任何操作发生竞争。但若列表中完全没有活动文件,则与其他情况一样进行验证。
无法读取的分段会被记录到日志中、从合并视图中排除,并在响应中标记 incomplete: true,而不是让整个查询失败,因为单个损坏的文件不应使调用方丢失所有其他分段。at_end 的作用范围随之限定为实际可读取的内容,而该标志正是告知调用方这一点的途径;守护进程自身的日志面向运维人员,而非需要决定是否继续翻页的客户端。单事件查找也遵循同样的区分:只要命中,结果即为权威结果,无论跳过了多少分段;但若未命中,且跳过的分段中可能包含目标,则应报告为历史记录不可读,而不是 not found。
归档发现会拒绝符号链接。仅匹配写入方的文件名格式并不构成路径限制。如果跟随链接,任何能够写入日志目录的人都可以让符合归档格式的文件名指向守护进程可读的任意文件。验证与打开是两个独立操作,因此能够替换日志目录中条目的攻击者,仍可能在归档被打开之前通过竞争条件将已验证的归档替换为符号链接。在读取方使用不跟随链接或基于文件描述符稳定的打开方式之前,部署时必须保持日志目录可信。
持久化策略负责重写和保留
config.rs 中的 StoragePolicy 仅控制 JSONL 的输出位置。Observer、OTLP 和广播的投递不受其影响,彼此保持独立。
| 策略 | 活动文件行为 | 留存负责人 |
|---|---|---|
none | 不会再写入新的 JSONL。 | 无。 |
rolling | 每次追加超过 max_entries 后,将保留的尾部条目重写到临时文件,然后将其重命名覆盖活动文件。 | 写入器会保留配置的活动窗口大小。它不会创建任何归档,也不会管理早期 rotating 配置留下的归档。 |
full | 追加写入,不进行由写入器管理的截断或轮转。 | 文件的增长以及任何外部轮换均由操作员负责。 |
rotating | 在新的 UTC 日期的第一次追加之前,或者当追加使已激活文件达到字节数或条目数阈值之后,将该活动文件重命名为带时间戳的归档文件。 | 每次轮转成功后,写入器会先按存留时间、再按数量清理匹配的归档文件。删除操作尽力而为,绝不会导致外层追加操作失败。 |
年龄和数量保留策略仅在轮转之后运行。它们不会持续扫描,不适用于 full 或 rolling,也不会删除相邻的任意文件:归档发现只接受根据活动路径的归档格式生成的文件名。在 rotating 下,实时 /api/logs 读取器会将活动文件与所有保留的归档合并;在其他策略下,它只读取活动文件。参见读取器游标。
模式迁移就是重写活动文件
启用持久化且活动路径存在时,writer::init_from_config 会在启动磁盘工作线程之前运行 migrate::migrate_legacy_jsonl_in_place。迁移器通过临时文件流式处理非空行,将包含 timestamp 但不含 @timestamp 的旧格式行进行转换,保留已是当前格式的行,跳过格式错误的 JSON 并发出警告,同步临时文件,然后将其原子重命名并覆盖活动路径。
迁移采用尽力而为策略。其廉价的架构检查会在第一条非空行处停止。迁移会在该行格式错误,或包含 timestamp 但不包含 @timestamp 时运行;任何其他可解析的 JSON 都会被视为当前格式,即使其属于未知或无效架构,因此后续的旧版行可能仍未完成迁移。如果迁移返回错误,初始化会发出警告并继续,因此后续的 v2 追加内容可能会与 v2 读取器无法反序列化的旧行共存。轮换后的归档不会迁移。
LogEvent 是架构的唯一事实来源。对于每次架构变更,评估迁移兼容性、活动文件反序列化、HTTP 和 RPC 序列化/使用方,以及架构和运维人员文档;仅更新行为或兼容性发生变化的边界。RPC 日志接口位于 crates/zeroclaw-runtime/src/rpc/types.rs 和 dispatch.rs 中。替换活动文件的迁移会使字节偏移游标失效,而不会重写现有字节的附加式兼容变更则不会。
LogConfig 与 ObservabilityConfig
zeroclaw-log 在 crates/zeroclaw-log/src/config.rs 中定义了自己的精简版 LogConfig,包含 log_persistence、log_persistence_path、log_persistence_max_entries、log_persistence_max_bytes、log_persistence_rotate_daily、log_persistence_retention_max_files、log_persistence_retention_max_age_days、log_persistence_max_entries_per_segment、log_tool_io、log_tool_io_truncate_bytes 和 log_tool_io_denylist。这样可以避免本应出现的依赖循环:zeroclaw-config::ObservabilityConfig 承载完整的 schema(包括 TOML 反序列化和校验),运行时在启动时以及守护进程配置重载后,通过 crates/zeroclaw-runtime/src/observability/runtime_trace.rs::to_log_config 将其转换为 LogConfig。结果是:zeroclaw-config 可以使用 record!,而无需颠倒依赖树,同时日志持久化和轮转策略的变更会在下一次守护进程重载时生效。
订阅者安装
守护进程通过以下方式安装全局订阅者:
#![allow(unused)]
fn main() {
zeroclaw_log::install_global_subscriber(
recording_filter.as_deref(), // Option<&str> — --log-level 标志(如果已设置)
&default_filter, // &str — 在没有标志和 RUST_LOG 时的后备过滤器
cli.verbose, // bool — 控制 stderr fmt(终端)层
);
}
两个独立的轴:录制底线(到达 LogCaptureLayer 的内容,按 flag → RUST_LOG → 默认值的顺序解析)与终端显示(stderr fmt 层,除非 verbose 为 true,否则完全静默)。这一次调用即可在 tracing-subscriber::Registry 之上设置带 agent 别名前缀的终端格式化器以及 LogCaptureLayer。src/main.rs 是唯一调用它的地方。测试使用 zeroclaw_log::try_install_capture_subscriber() + zeroclaw_log::subscribe_or_install(),通过广播钩子抽取已发出的事件,而无需在测试 crate 中命名任何 tracing 类型。
何时扩展封闭枚举
- 新增通道实现:向
ChannelKind添加一个变体。其 snake_case 形式即为磁盘上的channel_type字符串。仅当变体名称 snake_case 后无法得到期望值时(例如OpenAi→"openai"),才需添加#[strum(serialize = "...")]。 - 新工具实现(工作区内置):添加到
ToolKind。 - 新增 cron 调度结构:添加到
CronKind。 - 新模型 / TTS / 转录 / 隧道提供方:将其添加到
ProviderKind下相关的*ProviderKind子枚举中。 - 新增内存后端:添加到
MemoryKind。 - 全新的
Role系列(PeerGroup / Skill / Mcp 获得子类型):随时使用其自身的Kind进行嵌套:模式是统一的。
然后在新结构体旁添加 impl Attributable for X(fn role() -> Role::Family(Kind::Variant)、fn alias() -> &str { &self.alias }),并用 attribution_span!(self) 包裹其入口点。其余部分该层会自动处理。
操作符注意事项
有关配置项(log_persistence、log_tool_io、OTel 导出)和查询语法,请参阅日志与可观测性。