rustyclaw_core/observability/
log.rs1use super::traits::{Observer, ObserverEvent, ObserverMetric};
9use std::any::Any;
10use tracing::info;
11
12pub 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}