feat: add verbose logging and complete observability (#251)

This commit is contained in:
mai1015 2026-02-16 05:59:07 -05:00 committed by GitHub
parent 6d56a040ce
commit 50f508766f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 316 additions and 5 deletions

View file

@ -16,6 +16,35 @@ impl Observer for LogObserver {
ObserverEvent::AgentStart { provider, model } => {
info!(provider = %provider, model = %model, "agent.start");
}
ObserverEvent::LlmRequest {
provider,
model,
messages_count,
} => {
info!(
provider = %provider,
model = %model,
messages_count = messages_count,
"llm.request"
);
}
ObserverEvent::LlmResponse {
provider,
model,
duration,
success,
error_message,
} => {
let ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
info!(
provider = %provider,
model = %model,
duration_ms = ms,
success = success,
error = ?error_message,
"llm.response"
);
}
ObserverEvent::AgentEnd {
duration,
tokens_used,
@ -23,6 +52,9 @@ impl Observer for LogObserver {
let ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
info!(duration_ms = ms, tokens = ?tokens_used, "agent.end");
}
ObserverEvent::ToolCallStart { tool } => {
info!(tool = %tool, "tool.start");
}
ObserverEvent::ToolCall {
tool,
duration,
@ -31,6 +63,9 @@ impl Observer for LogObserver {
let ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
info!(tool = %tool, duration_ms = ms, success = success, "tool.call");
}
ObserverEvent::TurnComplete => {
info!("turn.complete");
}
ObserverEvent::ChannelMessage { channel, direction } => {
info!(channel = %channel, direction = %direction, "channel.message");
}
@ -83,6 +118,18 @@ mod tests {
provider: "openrouter".into(),
model: "claude-sonnet".into(),
});
obs.record_event(&ObserverEvent::LlmRequest {
provider: "openrouter".into(),
model: "claude-sonnet".into(),
messages_count: 2,
});
obs.record_event(&ObserverEvent::LlmResponse {
provider: "openrouter".into(),
model: "claude-sonnet".into(),
duration: Duration::from_millis(250),
success: true,
error_message: None,
});
obs.record_event(&ObserverEvent::AgentEnd {
duration: Duration::from_millis(500),
tokens_used: Some(100),
@ -91,11 +138,15 @@ mod tests {
duration: Duration::ZERO,
tokens_used: None,
});
obs.record_event(&ObserverEvent::ToolCallStart {
tool: "shell".into(),
});
obs.record_event(&ObserverEvent::ToolCall {
tool: "shell".into(),
duration: Duration::from_millis(10),
success: false,
});
obs.record_event(&ObserverEvent::TurnComplete);
obs.record_event(&ObserverEvent::ChannelMessage {
channel: "telegram".into(),
direction: "outbound".into(),

View file

@ -3,11 +3,14 @@ pub mod multi;
pub mod noop;
pub mod otel;
pub mod traits;
pub mod verbose;
pub use self::log::LogObserver;
pub use self::multi::MultiObserver;
pub use noop::NoopObserver;
pub use otel::OtelObserver;
pub use traits::{Observer, ObserverEvent};
pub use verbose::VerboseObserver;
use crate::config::ObservabilityConfig;

View file

@ -33,6 +33,18 @@ mod tests {
provider: "test".into(),
model: "test".into(),
});
obs.record_event(&ObserverEvent::LlmRequest {
provider: "test".into(),
model: "test".into(),
messages_count: 2,
});
obs.record_event(&ObserverEvent::LlmResponse {
provider: "test".into(),
model: "test".into(),
duration: Duration::from_millis(1),
success: true,
error_message: None,
});
obs.record_event(&ObserverEvent::AgentEnd {
duration: Duration::from_millis(100),
tokens_used: Some(42),
@ -41,11 +53,15 @@ mod tests {
duration: Duration::ZERO,
tokens_used: None,
});
obs.record_event(&ObserverEvent::ToolCallStart {
tool: "shell".into(),
});
obs.record_event(&ObserverEvent::ToolCall {
tool: "shell".into(),
duration: Duration::from_secs(1),
success: true,
});
obs.record_event(&ObserverEvent::TurnComplete);
obs.record_event(&ObserverEvent::ChannelMessage {
channel: "cli".into(),
direction: "inbound".into(),

View file

@ -15,6 +15,8 @@ pub struct OtelObserver {
// Metrics instruments
agent_starts: Counter<u64>,
agent_duration: Histogram<f64>,
llm_calls: Counter<u64>,
llm_duration: Histogram<f64>,
tool_calls: Counter<u64>,
tool_duration: Histogram<f64>,
channel_messages: Counter<u64>,
@ -89,6 +91,17 @@ impl OtelObserver {
.with_unit("s")
.build();
let llm_calls = meter
.u64_counter("zeroclaw.llm.calls")
.with_description("Total LLM provider calls")
.build();
let llm_duration = meter
.f64_histogram("zeroclaw.llm.duration")
.with_description("LLM provider call duration in seconds")
.with_unit("s")
.build();
let tool_calls = meter
.u64_counter("zeroclaw.tool.calls")
.with_description("Total tool calls")
@ -141,6 +154,8 @@ impl OtelObserver {
meter_provider: meter_provider_clone,
agent_starts,
agent_duration,
llm_calls,
llm_duration,
tool_calls,
tool_duration,
channel_messages,
@ -168,6 +183,45 @@ impl Observer for OtelObserver {
],
);
}
ObserverEvent::LlmRequest { .. } => {}
ObserverEvent::LlmResponse {
provider,
model,
duration,
success,
error_message: _,
} => {
let secs = duration.as_secs_f64();
let attrs = [
KeyValue::new("provider", provider.clone()),
KeyValue::new("model", model.clone()),
KeyValue::new("success", success.to_string()),
];
self.llm_calls.add(1, &attrs);
self.llm_duration.record(secs, &attrs);
// Create a completed span for visibility in trace backends.
let start_time = SystemTime::now()
.checked_sub(*duration)
.unwrap_or(SystemTime::now());
let mut span = tracer.build(
opentelemetry::trace::SpanBuilder::from_name("llm.call")
.with_kind(SpanKind::Internal)
.with_start_time(start_time)
.with_attributes(vec![
KeyValue::new("provider", provider.clone()),
KeyValue::new("model", model.clone()),
KeyValue::new("success", *success),
KeyValue::new("duration_s", secs),
]),
);
if *success {
span.set_status(Status::Ok);
} else {
span.set_status(Status::error(""));
}
span.end();
}
ObserverEvent::AgentEnd {
duration,
tokens_used,
@ -193,6 +247,7 @@ impl Observer for OtelObserver {
// Note: tokens are recorded via record_metric(TokensUsed) to avoid
// double-counting. AgentEnd only records duration.
}
ObserverEvent::ToolCallStart { .. } => {}
ObserverEvent::ToolCall {
tool,
duration,
@ -230,6 +285,7 @@ impl Observer for OtelObserver {
self.tool_duration
.record(secs, &[KeyValue::new("tool", tool.clone())]);
}
ObserverEvent::TurnComplete => {}
ObserverEvent::ChannelMessage { channel, direction } => {
self.channel_messages.add(
1,
@ -323,6 +379,18 @@ mod tests {
provider: "openrouter".into(),
model: "claude-sonnet".into(),
});
obs.record_event(&ObserverEvent::LlmRequest {
provider: "openrouter".into(),
model: "claude-sonnet".into(),
messages_count: 2,
});
obs.record_event(&ObserverEvent::LlmResponse {
provider: "openrouter".into(),
model: "claude-sonnet".into(),
duration: Duration::from_millis(250),
success: true,
error_message: None,
});
obs.record_event(&ObserverEvent::AgentEnd {
duration: Duration::from_millis(500),
tokens_used: Some(100),
@ -331,6 +399,9 @@ mod tests {
duration: Duration::ZERO,
tokens_used: None,
});
obs.record_event(&ObserverEvent::ToolCallStart {
tool: "shell".into(),
});
obs.record_event(&ObserverEvent::ToolCall {
tool: "shell".into(),
duration: Duration::from_millis(10),
@ -341,6 +412,7 @@ mod tests {
duration: Duration::from_millis(5),
success: false,
});
obs.record_event(&ObserverEvent::TurnComplete);
obs.record_event(&ObserverEvent::ChannelMessage {
channel: "telegram".into(),
direction: "inbound".into(),

View file

@ -7,15 +7,38 @@ pub enum ObserverEvent {
provider: String,
model: String,
},
/// A request is about to be sent to an LLM provider.
///
/// This is emitted immediately before a provider call so observers can print
/// user-facing progress without leaking prompt contents.
LlmRequest {
provider: String,
model: String,
messages_count: usize,
},
/// Result of a single LLM provider call.
LlmResponse {
provider: String,
model: String,
duration: Duration,
success: bool,
error_message: Option<String>,
},
AgentEnd {
duration: Duration,
tokens_used: Option<u64>,
},
/// A tool call is about to be executed.
ToolCallStart {
tool: String,
},
ToolCall {
tool: String,
duration: Duration,
success: bool,
},
/// The agent produced a final answer for the current user message.
TurnComplete,
ChannelMessage {
channel: String,
direction: String,

View file

@ -0,0 +1,96 @@
use super::traits::{Observer, ObserverEvent, ObserverMetric};
/// Human-readable progress observer for interactive CLI sessions.
///
/// This observer prints compact `>` / `<` progress lines without exposing
/// prompt contents. It is intended to be opt-in (e.g. `--verbose`).
pub struct VerboseObserver;
impl VerboseObserver {
pub fn new() -> Self {
Self
}
}
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, ..
} => {
let ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
eprintln!("< Receive (success={success}, duration_ms={ms})");
}
ObserverEvent::ToolCallStart { tool } => {
eprintln!("> Tool {tool}");
}
ObserverEvent::ToolCall {
tool,
duration,
success,
} => {
let ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
eprintln!("< Tool {tool} (success={success}, duration_ms={ms})");
}
ObserverEvent::TurnComplete => {
eprintln!("< Complete");
}
_ => {}
}
}
#[inline(always)]
fn record_metric(&self, _metric: &ObserverMetric) {}
fn name(&self) -> &str {
"verbose"
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn verbose_name() {
assert_eq!(VerboseObserver::new().name(), "verbose");
}
#[test]
fn verbose_events_do_not_panic() {
let obs = VerboseObserver::new();
obs.record_event(&ObserverEvent::LlmRequest {
provider: "openrouter".into(),
model: "claude".into(),
messages_count: 3,
});
obs.record_event(&ObserverEvent::LlmResponse {
provider: "openrouter".into(),
model: "claude".into(),
duration: Duration::from_millis(12),
success: true,
error_message: None,
});
obs.record_event(&ObserverEvent::ToolCallStart {
tool: "shell".into(),
});
obs.record_event(&ObserverEvent::ToolCall {
tool: "shell".into(),
duration: Duration::from_millis(2),
success: true,
});
obs.record_event(&ObserverEvent::TurnComplete);
}
}