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 框架裡設計可插拔後端」的參考範本。