zeroclaw 的 observability 模組是個教科書等級的 Rust trait-based plugin 設計。它讓你在編譯期決定要不要開啟可觀測性,同時支援 Prometheus、OpenTelemetry 等多種後端,還能零成本地完全關閉。
問題:框架的 Observability 該怎麼設計?
你寫了一個 AI agent 框架,你想讓使用者可以選擇怎麼觀察它的行為——有人想接 Prometheus,有人想接 OpenTelemetry,有人根本不在乎、只要 log 就好,還有人在測試時一個 I/O 都不想多做。
這種「插件式後端選擇」的設計,zeroclaw 用了一個很乾淨的 trait object 架構來解決。
核心抽象:Observer Trait
src/observability/traits.rs 定義了整個系統的骨架:
pub trait Observer: Send + Sync + 'static {
fn record_event(&self, event: &ObserverEvent);
fn record_metric(&self, metric: &ObserverMetric);
fn flush(&self) {}
fn name(&self) -> &str;
fn as_any(&self) -> &dyn std::any::Any;
}Send + Sync + 'static 是為了讓 Arc<dyn Observer> 可以跨 async task 傳遞——這個組合在 tokio 生態系幾乎是標配。
flush() 有預設實作(空的),只有需要批次傳送的後端(例如 OTel)才需要覆寫。
as_any() 則是個小技巧,讓你可以在必要時從 dyn Observer 向下轉型(downcast)到具體型別——後面會看到它的用途。
兩種觀測維度:Event vs Metric
zeroclaw 把可觀測的資訊分成兩類:
ObserverEvent:領域事件
pub enum ObserverEvent {
AgentStart { provider: String, model: String },
LlmRequest { provider: String, model: String, messages_count: usize },
LlmResponse {
provider: String,
model: String,
duration: Duration,
success: bool,
error_message: Option<String>,
},
AgentEnd {
provider: String,
model: String,
duration: Duration,
tokens_used: Option<u64>,
cost_usd: Option<f64>,
},
ToolCallStart { tool: String },
ToolCall { tool: String, duration: Duration, success: bool },
TurnComplete,
ChannelMessage { channel: String, direction: String },
HeartbeatTick,
Error { component: String, message: String },
}Event 是「某件事發生了」——agent 啟動、LLM 回應、工具被呼叫等。每個 variant 都帶有足夠的上下文,讓後端可以根據需要提取資訊。
ObserverMetric:數值度量
pub enum ObserverMetric {
RequestLatency(Duration),
TokensUsed(u64),
ActiveSessions(u64),
QueueDepth(u64),
}Metric 是「一個數值」——延遲、用量、隊列深度。這類資料更適合作為 gauge 或 histogram 送到監控系統。
這個分法很務實:Prometheus 要的是數值,OTel 兩者都要,log 後端則把兩者都轉成文字。
後端實作:從零成本到全功能
Noop:真正的零成本
pub struct NoopObserver;
impl Observer for NoopObserver {
#[inline(always)]
fn record_event(&self, _event: &ObserverEvent) {}
#[inline(always)]
fn record_metric(&self, _metric: &ObserverMetric) {}
fn name(&self) -> &str { "noop" }
fn as_any(&self) -> &dyn Any { self }
}#[inline(always)] 是關鍵。LLVM 看到這個後會把所有呼叫點直接展開為空操作,最終生成的機器碼裡根本沒有 observer 相關的指令。這就是 Rust 的「零成本抽象」在實務中的樣子。
Log:接 tracing 生態系
impl Observer for LogObserver {
fn record_event(&self, event: &ObserverEvent) {
match event {
ObserverEvent::AgentStart { provider, model } => {
info!(provider = %provider, model = %model, "agent.start");
}
ObserverEvent::LlmResponse { duration, success, .. } => {
info!(duration_ms = duration.as_millis(), success, "llm.response");
}
// ...
}
}
}用 tracing crate 做結構化 logging,每個事件對應一條有 key-value 欄位的 log 記錄。
Verbose:給互動式 CLI 的人看
impl Observer for VerboseObserver {
fn record_event(&self, event: &ObserverEvent) {
match event {
ObserverEvent::LlmRequest { provider, model, messages_count } => {
eprintln!("> Thinking");
eprintln!("> Send (provider={}, model={}, messages={})",
provider, model, messages_count);
}
ObserverEvent::LlmResponse { duration, success, .. } => {
eprintln!("< Receive (success={}, duration_ms={})",
success, duration.as_millis());
}
// ...
}
}
}用 > 和 < 表示「送出」和「收到」,讓使用者在終端機就能感受到 agent 的呼吸感,不需要看 log 或架監控。
Prometheus:指標抓取
pub struct PrometheusObserver {
registry: Registry,
agent_starts: IntCounterVec,
agent_duration: HistogramVec,
llm_calls: IntCounterVec,
llm_duration: Histogram,
tool_calls: IntCounterVec,
// ...
}每個欄位對應一個 Prometheus 指標。Event 進來後,依照類型對相應的 counter 或 histogram 做操作。
Prometheus 後端有個特別之處:它需要暴露一個 /metrics HTTP endpoint 讓 scraper 來抓。但 Observer trait 本身沒有這個方法——這就是 as_any() 的用武之地:
// 在 gateway 需要回應 /metrics 請求時
state.observer
.as_any()
.downcast_ref::<PrometheusObserver>()
.map(|prom| prom.encode())透過 downcast 繞過了 trait 的限制,讓 Prometheus 特有的操作可以被存取,而不需要把這個方法塞進通用的 Observer trait。
OpenTelemetry:完整的追蹤 + 指標
pub struct OtelObserver {
tracer_provider: SdkTracerProvider,
meter_provider: SdkMeterProvider,
agent_starts: Counter<u64>,
agent_duration: Histogram<f64>,
llm_calls: Counter<u64>,
llm_duration: Histogram<f64>,
// ...
}OTel 後端最有意思的地方是它會從 event 裡還原 span:
// 收到 LlmResponse 事件後,用 duration 計算出 span 的開始時間
let start_time = SystemTime::now() - duration;
let mut span = tracer.build(
SpanBuilder::from_name("llm.call")
.with_start_time(start_time)
.with_attributes(vec![
KeyValue::new("provider", provider.clone()),
KeyValue::new("model", model.clone()),
KeyValue::new("success", *success),
])
);
span.set_status(if *success { Status::Ok } else { Status::error("") });
span.end();因為 ObserverEvent 是事後才收到的(帶有 duration 欄位),OTel 後端就把開始時間「倒推」回去,製造出一個正確時間範圍的 span。這讓整個系統可以保持 event-sourced 的設計,而不需要在程式碼的每個角落都維護 span context。
Multi:扇出組合
pub struct MultiObserver {
observers: Vec<Box<dyn Observer>>,
}
impl Observer for MultiObserver {
fn record_event(&self, event: &ObserverEvent) {
for obs in &self.observers {
obs.record_event(event);
}
}
}想同時送 Prometheus 又送 OTel?把它們包進 MultiObserver 就好。組合模式(Composite Pattern)在這裡用得剛好。
工廠函數:從設定到實體
pub fn create_observer(config: &ObservabilityConfig) -> Box<dyn Observer> {
match config.backend.as_str() {
"log" => Box::new(LogObserver::new()),
"prometheus" => Box::new(PrometheusObserver::new()),
"otel" | "opentelemetry" | "otlp" => {
match OtelObserver::new(config.otel_endpoint.as_deref(), ...) {
Ok(obs) => Box::new(obs),
Err(e) => {
tracing::error!("Failed to create OTel observer: {e}. Falling back to noop.");
Box::new(NoopObserver)
}
}
}
_ => Box::new(NoopObserver),
}
}設定結構很精簡:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObservabilityConfig {
pub backend: String, // "none" | "log" | "prometheus" | "otel"
pub otel_endpoint: Option<String>, // 預設 http://localhost:4318
pub otel_service_name: Option<String>, // 預設 "zeroclaw"
}
impl Default for ObservabilityConfig {
fn default() -> Self {
Self { backend: "none".into(), .. }
}
}預設是 "none",也就是 NoopObserver——除非你明確設定,否則完全沒有額外開銷。
這個工廠函數還有個好設計:OTel 初始化失敗(例如 endpoint 連不上)時,它不會 panic,而是靜默降級成 Noop,同時印一個 error log。可觀測性本身不應該成為系統的單點故障。
整合:Agent 和 Gateway 的用法
Agent 和 Gateway 都用 Arc<dyn Observer> 持有 observer:
pub struct Agent {
observer: Arc<dyn Observer>,
// ...
}整個 agent 生命週期裡,事件就這樣流出去:
self.observer.record_event(&ObserverEvent::AgentStart {
provider: provider_name.clone(),
model: self.model_name.clone(),
});
// ... 執行 LLM 呼叫 ...
self.observer.record_event(&ObserverEvent::LlmResponse {
provider: provider_name.clone(),
model: self.model_name.clone(),
duration,
success: true,
error_message: None,
});Arc 讓 observer 可以廉價地被 clone 並跨 async task 共享,每個後端自己處理執行緒安全的問題。
設計亮點總結
zeroclaw 的 observability 層之所以好,是因為它做了幾個清晰的取捨:
| 設計決策 | 理由 |
|---|---|
Arc<dyn Observer> 而非泛型參數 |
避免 generic viral 污染整個 call stack |
| 領域 enum 而非字串 event | 型別安全,編譯器幫你確認所有 variant 都有處理 |
NoopObserver + #[inline(always)] |
確保關閉時真的零成本,不只是概念上的零成本 |
| 工廠函數失敗時降級 | 可觀測性不是核心功能,不能影響主流程 |
as_any() 開後門 |
讓 Prometheus 這類需要特殊 API 的後端有辦法被使用,而不需要污染通用 trait |
| OTel 用倒推時間還原 span | 保持呼叫點的 API 簡單,不需要到處傳 span context |
這套設計很適合作為「如何在 Rust 框架裡設計可插拔後端」的參考範本。