Skip to main content

rustyclaw_core/observability/
log.rs

1//! Log-based observer implementation.
2//!
3//! Zero external dependencies beyond `tracing`. Writes events and metrics
4//! as structured logs via the tracing framework.
5//!
6//! Adapted from ZeroClaw (MIT OR Apache-2.0 licensed).
7
8use super::traits::{Observer, ObserverEvent, ObserverMetric};
9use std::any::Any;
10use tracing::info;
11
12/// Log-based observer — uses tracing, zero external deps
13pub struct LogObserver;
14
15impl LogObserver {
16    pub fn new() -> Self {
17        Self
18    }
19}
20
21impl Default for LogObserver {
22    fn default() -> Self {
23        Self::new()
24    }
25}
26
27impl Observer for LogObserver {
28    fn record_event(&self, event: &ObserverEvent) {
29        match event {
30            ObserverEvent::AgentStart { provider, model } => {
31                info!(provider = %provider, model = %model, "agent.start");
32            }
33            ObserverEvent::AgentEnd {
34                provider,
35                model,
36                duration,
37                tokens_used,
38                cost_usd,
39            } => {
40                let ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
41                info!(provider = %provider, model = %model, duration_ms = ms, tokens = ?tokens_used, cost_usd = ?cost_usd, "agent.end");
42            }
43            ObserverEvent::ToolCallStart { tool } => {
44                info!(tool = %tool, "tool.start");
45            }
46            ObserverEvent::ToolCall {
47                tool,
48                duration,
49                success,
50            } => {
51                let ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
52                info!(tool = %tool, duration_ms = ms, success = success, "tool.call");
53            }
54            ObserverEvent::TurnComplete => {
55                info!("turn.complete");
56            }
57            ObserverEvent::ChannelMessage { channel, direction } => {
58                info!(channel = %channel, direction = %direction, "channel.message");
59            }
60            ObserverEvent::HeartbeatTick => {
61                info!("heartbeat.tick");
62            }
63            ObserverEvent::Error { component, message } => {
64                info!(component = %component, error = %message, "error");
65            }
66            ObserverEvent::LlmRequest {
67                provider,
68                model,
69                messages_count,
70            } => {
71                info!(
72                    provider = %provider,
73                    model = %model,
74                    messages_count = messages_count,
75                    "llm.request"
76                );
77            }
78            ObserverEvent::LlmResponse {
79                provider,
80                model,
81                duration,
82                success,
83                error_message,
84                input_tokens,
85                output_tokens,
86            } => {
87                let ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
88                info!(
89                    provider = %provider,
90                    model = %model,
91                    duration_ms = ms,
92                    success = success,
93                    error = ?error_message,
94                    input_tokens = ?input_tokens,
95                    output_tokens = ?output_tokens,
96                    "llm.response"
97                );
98            }
99        }
100    }
101
102    fn record_metric(&self, metric: &ObserverMetric) {
103        match metric {
104            ObserverMetric::RequestLatency(d) => {
105                let ms = u64::try_from(d.as_millis()).unwrap_or(u64::MAX);
106                info!(latency_ms = ms, "metric.request_latency");
107            }
108            ObserverMetric::TokensUsed(t) => {
109                info!(tokens = t, "metric.tokens_used");
110            }
111            ObserverMetric::ActiveSessions(s) => {
112                info!(sessions = s, "metric.active_sessions");
113            }
114            ObserverMetric::QueueDepth(d) => {
115                info!(depth = d, "metric.queue_depth");
116            }
117        }
118    }
119
120    fn name(&self) -> &str {
121        "log"
122    }
123
124    fn as_any(&self) -> &dyn Any {
125        self
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use std::time::Duration;
133
134    #[test]
135    fn log_observer_name() {
136        assert_eq!(LogObserver::new().name(), "log");
137    }
138
139    #[test]
140    fn log_observer_all_events_no_panic() {
141        let obs = LogObserver::new();
142        obs.record_event(&ObserverEvent::AgentStart {
143            provider: "openrouter".into(),
144            model: "claude-sonnet".into(),
145        });
146        obs.record_event(&ObserverEvent::AgentEnd {
147            provider: "openrouter".into(),
148            model: "claude-sonnet".into(),
149            duration: Duration::from_millis(500),
150            tokens_used: Some(100),
151            cost_usd: Some(0.0015),
152        });
153        obs.record_event(&ObserverEvent::AgentEnd {
154            provider: "openrouter".into(),
155            model: "claude-sonnet".into(),
156            duration: Duration::ZERO,
157            tokens_used: None,
158            cost_usd: None,
159        });
160        obs.record_event(&ObserverEvent::LlmResponse {
161            provider: "openrouter".into(),
162            model: "claude-sonnet".into(),
163            duration: Duration::from_millis(150),
164            success: true,
165            error_message: None,
166            input_tokens: Some(100),
167            output_tokens: Some(50),
168        });
169        obs.record_event(&ObserverEvent::LlmResponse {
170            provider: "openrouter".into(),
171            model: "claude-sonnet".into(),
172            duration: Duration::from_millis(200),
173            success: false,
174            error_message: Some("rate limited".into()),
175            input_tokens: None,
176            output_tokens: None,
177        });
178        obs.record_event(&ObserverEvent::ToolCall {
179            tool: "shell".into(),
180            duration: Duration::from_millis(10),
181            success: false,
182        });
183        obs.record_event(&ObserverEvent::ChannelMessage {
184            channel: "telegram".into(),
185            direction: "outbound".into(),
186        });
187        obs.record_event(&ObserverEvent::HeartbeatTick);
188        obs.record_event(&ObserverEvent::Error {
189            component: "provider".into(),
190            message: "timeout".into(),
191        });
192    }
193
194    #[test]
195    fn log_observer_all_metrics_no_panic() {
196        let obs = LogObserver::new();
197        obs.record_metric(&ObserverMetric::RequestLatency(Duration::from_secs(2)));
198        obs.record_metric(&ObserverMetric::TokensUsed(0));
199        obs.record_metric(&ObserverMetric::TokensUsed(u64::MAX));
200        obs.record_metric(&ObserverMetric::ActiveSessions(1));
201        obs.record_metric(&ObserverMetric::QueueDepth(999));
202    }
203}