zeroclaw_runtime/observability/
log.rs1use super::traits::{Observer, ObserverEvent, ObserverMetric};
2use std::any::Any;
3
4pub 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}