mcpr_integrations/sinks/
stderr_sink.rs1use std::io::Write;
6
7use mcpr_core::event::{EventSink, ProxyEvent};
8use mcpr_core::time::format_latency_us;
9
10#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
14pub enum LogFormat {
15 #[default]
16 Json,
17 Pretty,
18}
19
20impl std::str::FromStr for LogFormat {
21 type Err = String;
22 fn from_str(s: &str) -> Result<Self, Self::Err> {
23 match s.to_lowercase().as_str() {
24 "json" => Ok(LogFormat::Json),
25 "pretty" | "text" => Ok(LogFormat::Pretty),
26 _ => Err(format!("unknown log format: {s} (expected: json, pretty)")),
27 }
28 }
29}
30
31impl std::fmt::Display for LogFormat {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 match self {
34 LogFormat::Json => write!(f, "json"),
35 LogFormat::Pretty => write!(f, "pretty"),
36 }
37 }
38}
39
40pub struct StderrSink {
44 format: LogFormat,
45}
46
47impl StderrSink {
48 pub fn new(format: LogFormat) -> Self {
49 Self { format }
50 }
51}
52
53impl EventSink for StderrSink {
54 fn on_event(&self, event: &ProxyEvent) {
55 let ProxyEvent::Request(e) = event else {
57 return;
58 };
59
60 let line = match self.format {
61 LogFormat::Json => match serde_json::to_string(event) {
62 Ok(json) => json,
63 Err(_) => return,
64 },
65 LogFormat::Pretty => {
66 let status = e.status;
67 let method = &e.method;
68 let path = &e.path;
69 let duration = format_latency_us(e.latency_us as i64);
70 let size = e
71 .response_size
72 .map(|b| {
73 if b >= 1024 {
74 format!(" {:.1}KB", b as f64 / 1024.0)
75 } else {
76 format!(" {b}B")
77 }
78 })
79 .unwrap_or_default();
80 let mcp = e
81 .mcp_method
82 .as_deref()
83 .map(|m| format!(" {m}"))
84 .unwrap_or_default();
85 let detail = e
86 .tool
87 .as_deref()
88 .map(|d| format!(" -> {d}"))
89 .unwrap_or_default();
90
91 let ts = chrono::DateTime::from_timestamp_millis(e.ts)
92 .map(|dt| {
93 dt.with_timezone(&chrono::Local)
94 .format("%H:%M:%S")
95 .to_string()
96 })
97 .unwrap_or_default();
98
99 format!("{ts} {method} {status}{size} {duration}{mcp}{detail} {path}")
100 }
101 };
102
103 let stderr = std::io::stderr();
104 let mut handle = stderr.lock();
105 let _ = writeln!(handle, "{line}");
106 }
107
108 fn flush(&self) {
109 let _ = std::io::stderr().flush();
110 }
111
112 fn name(&self) -> &'static str {
113 "stderr"
114 }
115}
116
117#[cfg(test)]
118#[allow(non_snake_case)]
119mod tests {
120 use super::*;
121 use mcpr_core::event::RequestEvent;
122
123 fn make_event(latency_us: u64) -> ProxyEvent {
124 ProxyEvent::Request(Box::new(RequestEvent {
125 id: "test".into(),
126 ts: 1_700_000_000_000,
127 proxy: "api".into(),
128 session_id: None,
129 method: "POST".into(),
130 path: "/mcp".into(),
131 mcp_method: Some("tools/call".into()),
132 tool: Some("search".into()),
133 resource_uri: None,
134 prompt_name: None,
135 status: 200,
136 latency_us,
137 upstream_us: None,
138 request_size: Some(100),
139 response_size: Some(200),
140 error_code: None,
141 error_msg: None,
142 client_name: None,
143 client_version: None,
144 note: "test".into(),
145 stage_timings: None,
146 }))
147 }
148
149 #[test]
150 fn stderr_sink__pretty_sub_ms_latency() {
151 let sink = StderrSink::new(LogFormat::Pretty);
152 let event = make_event(200);
153 sink.on_event(&event);
154 }
155
156 #[test]
157 fn stderr_sink__pretty_ms_latency() {
158 let sink = StderrSink::new(LogFormat::Pretty);
159 let event = make_event(4_200);
160 sink.on_event(&event);
161 }
162
163 #[test]
164 fn stderr_sink__pretty_seconds_latency() {
165 let sink = StderrSink::new(LogFormat::Pretty);
166 let event = make_event(1_500_000);
167 sink.on_event(&event);
168 }
169
170 #[test]
171 fn stderr_sink__json_contains_latency_us() {
172 let event = make_event(200);
173 let json = serde_json::to_string(&event).unwrap();
174 assert!(json.contains("\"latency_us\":200"));
175 assert!(!json.contains("latency_ms"));
176 }
177
178 #[test]
179 fn log_format__parses_known_strings() {
180 assert_eq!("json".parse::<LogFormat>().unwrap(), LogFormat::Json);
181 assert_eq!("pretty".parse::<LogFormat>().unwrap(), LogFormat::Pretty);
182 assert_eq!("text".parse::<LogFormat>().unwrap(), LogFormat::Pretty);
183 assert!("xml".parse::<LogFormat>().is_err());
184 }
185}