Skip to main content

zeroclaw_runtime/observability/
log.rs

1use super::traits::{Observer, ObserverEvent, ObserverMetric};
2use std::any::Any;
3
4/// Log-based observer — uses tracing, zero external deps
5pub struct LogObserver;
6
7impl Default for LogObserver {
8    fn default() -> Self {
9        Self::new()
10    }
11}
12
13impl LogObserver {
14    pub fn new() -> Self {
15        Self
16    }
17}
18
19impl Observer for LogObserver {
20    fn record_event(&self, event: &ObserverEvent) {
21        match event {
22            ObserverEvent::AgentStart {
23                model_provider,
24                model,
25            } => {
26                ::zeroclaw_log::record!(
27                    INFO,
28                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
29                        .with_attrs(
30                            ::serde_json::json!({"model_provider": model_provider, "model": model})
31                        ),
32                    "agent.start"
33                );
34            }
35            ObserverEvent::AgentEnd {
36                model_provider,
37                model,
38                duration,
39                tokens_used,
40                cost_usd,
41            } => {
42                let ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
43                ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"model_provider": model_provider, "model": model, "duration_ms": ms, "tokens": tokens_used, "cost_usd": cost_usd})), "agent.end");
44            }
45            ObserverEvent::ToolCallStart { tool, .. } => {
46                ::zeroclaw_log::record!(
47                    INFO,
48                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
49                        .with_attrs(::serde_json::json!({"tool": tool})),
50                    "tool.start"
51                );
52            }
53            ObserverEvent::ToolCall {
54                tool,
55                duration,
56                success,
57                ..
58            } => {
59                let ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
60                ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"tool": tool, "duration_ms": ms, "success": success})), "tool.call");
61            }
62            ObserverEvent::TurnComplete => {
63                ::zeroclaw_log::record!(
64                    INFO,
65                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
66                    "turn.complete"
67                );
68            }
69            ObserverEvent::ChannelMessage { channel, direction } => {
70                ::zeroclaw_log::record!(
71                    INFO,
72                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
73                        .with_attrs(
74                            ::serde_json::json!({"channel": channel, "direction": direction})
75                        ),
76                    "channel.message"
77                );
78            }
79            ObserverEvent::HeartbeatTick => {
80                ::zeroclaw_log::record!(
81                    INFO,
82                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
83                    "heartbeat.tick"
84                );
85            }
86            ObserverEvent::CacheHit {
87                cache_type,
88                tokens_saved,
89            } => {
90                ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"cache_type": cache_type, "tokens_saved": tokens_saved})), "cache.hit");
91            }
92            ObserverEvent::CacheMiss { cache_type } => {
93                ::zeroclaw_log::record!(
94                    INFO,
95                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
96                        .with_attrs(::serde_json::json!({"cache_type": cache_type})),
97                    "cache.miss"
98                );
99            }
100            ObserverEvent::Error { component, message } => {
101                ::zeroclaw_log::record!(
102                    INFO,
103                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
104                        .with_attrs(
105                            ::serde_json::json!({"component": component, "error": message})
106                        ),
107                    "error"
108                );
109            }
110            ObserverEvent::LlmRequest {
111                model_provider,
112                model,
113                messages_count,
114            } => {
115                ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"model_provider": model_provider, "model": model, "messages_count": messages_count})), "llm.request");
116            }
117            ObserverEvent::LlmResponse {
118                model_provider,
119                model,
120                duration,
121                success,
122                error_message,
123                input_tokens,
124                output_tokens,
125            } => {
126                let ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
127                ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"model_provider": model_provider, "model": model, "duration_ms": ms, "success": success, "error": error_message, "input_tokens": input_tokens, "output_tokens": output_tokens})), "llm.response");
128            }
129            ObserverEvent::DeploymentStarted { deploy_id } => {
130                ::zeroclaw_log::record!(
131                    INFO,
132                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
133                        .with_attrs(::serde_json::json!({"deploy_id": deploy_id})),
134                    "deployment.started"
135                );
136            }
137            ObserverEvent::DeploymentCompleted {
138                deploy_id,
139                commit_sha,
140            } => {
141                ::zeroclaw_log::record!(
142                    INFO,
143                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
144                        .with_attrs(
145                            ::serde_json::json!({"deploy_id": deploy_id, "commit_sha": commit_sha})
146                        ),
147                    "deployment.completed"
148                );
149            }
150            ObserverEvent::DeploymentFailed { deploy_id, reason } => {
151                ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"deploy_id": deploy_id, "reason": reason.to_string()})), "deployment.failed");
152            }
153            ObserverEvent::RecoveryCompleted { deploy_id } => {
154                ::zeroclaw_log::record!(
155                    INFO,
156                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
157                        .with_attrs(::serde_json::json!({"deploy_id": deploy_id})),
158                    "recovery.completed"
159                );
160            }
161        }
162    }
163
164    fn record_metric(&self, metric: &ObserverMetric) {
165        match metric {
166            ObserverMetric::RequestLatency(d) => {
167                let ms = u64::try_from(d.as_millis()).unwrap_or(u64::MAX);
168                ::zeroclaw_log::record!(
169                    INFO,
170                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
171                        .with_attrs(::serde_json::json!({"latency_ms": ms})),
172                    "metric.request_latency"
173                );
174            }
175            ObserverMetric::TokensUsed(t) => {
176                ::zeroclaw_log::record!(
177                    INFO,
178                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
179                        .with_attrs(::serde_json::json!({"tokens": t})),
180                    "metric.tokens_used"
181                );
182            }
183            ObserverMetric::ActiveSessions(s) => {
184                ::zeroclaw_log::record!(
185                    INFO,
186                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
187                        .with_attrs(::serde_json::json!({"sessions": s})),
188                    "metric.active_sessions"
189                );
190            }
191            ObserverMetric::QueueDepth(d) => {
192                ::zeroclaw_log::record!(
193                    INFO,
194                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
195                        .with_attrs(::serde_json::json!({"depth": d})),
196                    "metric.queue_depth"
197                );
198            }
199            ObserverMetric::DeploymentLeadTime(d) => {
200                let ms = u64::try_from(d.as_millis()).unwrap_or(u64::MAX);
201                ::zeroclaw_log::record!(
202                    INFO,
203                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
204                        .with_attrs(::serde_json::json!({"lead_time_ms": ms})),
205                    "metric.deployment_lead_time"
206                );
207            }
208            ObserverMetric::RecoveryTime(d) => {
209                let ms = u64::try_from(d.as_millis()).unwrap_or(u64::MAX);
210                ::zeroclaw_log::record!(
211                    INFO,
212                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
213                        .with_attrs(::serde_json::json!({"recovery_time_ms": ms})),
214                    "metric.recovery_time"
215                );
216            }
217        }
218    }
219
220    fn name(&self) -> &str {
221        "log"
222    }
223
224    fn as_any(&self) -> &dyn Any {
225        self
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use std::time::Duration;
233
234    #[test]
235    fn log_observer_name() {
236        assert_eq!(LogObserver::new().name(), "log");
237    }
238
239    #[test]
240    fn log_observer_all_events_no_panic() {
241        let obs = LogObserver::new();
242        obs.record_event(&ObserverEvent::AgentStart {
243            model_provider: "openrouter".into(),
244            model: "claude-sonnet".into(),
245        });
246        obs.record_event(&ObserverEvent::AgentEnd {
247            model_provider: "openrouter".into(),
248            model: "claude-sonnet".into(),
249            duration: Duration::from_millis(500),
250            tokens_used: Some(100),
251            cost_usd: Some(0.0015),
252        });
253        obs.record_event(&ObserverEvent::AgentEnd {
254            model_provider: "openrouter".into(),
255            model: "claude-sonnet".into(),
256            duration: Duration::ZERO,
257            tokens_used: None,
258            cost_usd: None,
259        });
260        obs.record_event(&ObserverEvent::LlmResponse {
261            model_provider: "openrouter".into(),
262            model: "claude-sonnet".into(),
263            duration: Duration::from_millis(150),
264            success: true,
265            error_message: None,
266            input_tokens: Some(100),
267            output_tokens: Some(50),
268        });
269        obs.record_event(&ObserverEvent::LlmResponse {
270            model_provider: "openrouter".into(),
271            model: "claude-sonnet".into(),
272            duration: Duration::from_millis(200),
273            success: false,
274            error_message: Some("rate limited".into()),
275            input_tokens: None,
276            output_tokens: None,
277        });
278        obs.record_event(&ObserverEvent::ToolCall {
279            tool: "shell".into(),
280            tool_call_id: None,
281            duration: Duration::from_millis(10),
282            success: false,
283            arguments: None,
284            result: None,
285        });
286        obs.record_event(&ObserverEvent::ChannelMessage {
287            channel: "telegram".into(),
288            direction: "outbound".into(),
289        });
290        obs.record_event(&ObserverEvent::HeartbeatTick);
291        obs.record_event(&ObserverEvent::Error {
292            component: "model_provider".into(),
293            message: "timeout".into(),
294        });
295    }
296
297    #[test]
298    fn log_observer_all_metrics_no_panic() {
299        let obs = LogObserver::new();
300        obs.record_metric(&ObserverMetric::RequestLatency(Duration::from_secs(2)));
301        obs.record_metric(&ObserverMetric::TokensUsed(0));
302        obs.record_metric(&ObserverMetric::TokensUsed(u64::MAX));
303        obs.record_metric(&ObserverMetric::ActiveSessions(1));
304        obs.record_metric(&ObserverMetric::QueueDepth(999));
305    }
306}