Skip to main content

mcpr_integrations/sinks/
stderr_sink.rs

1//! Stderr event sink — prints proxy events to stderr for real-time visibility.
2//!
3//! Used in both daemon and foreground modes. Docker/k8s scrape stderr.
4
5use std::io::Write;
6
7use mcpr_core::event::{EventSink, ProxyEvent};
8use mcpr_core::time::format_latency_us;
9
10// ── Log format ──────────────────────────────────────────────────────────
11
12/// Log output format for stderr.
13#[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
40// ── Sink ────────────────────────────────────────────────────────────────
41
42/// Sink that prints proxy events to stderr.
43pub 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        // Only print request events to stderr (the console log line).
56        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}