Skip to main content

wide_event/
format.rs

1//! Output formatters for wide events.
2//!
3//! - [`JsonFormatter`] — one JSON object per line (default)
4//! - [`LogfmtFormatter`] — `key=value` pairs per line
5
6use std::collections::HashMap;
7use std::io::{self, Write};
8
9use serde::Serialize;
10use serde_json::Value;
11
12use crate::WideEventRecord;
13
14/// Trait for formatting a wide event record into a writer.
15///
16/// The `timestamp` parameter is pre-formatted by the layer's
17/// [`FormatTime`](crate::FormatTime) implementation — formatters
18/// should use it as-is.
19pub trait WideEventFormatter: Send + Sync {
20    /// Format a single wide event record and write it to `w`.
21    ///
22    /// # Errors
23    ///
24    /// Returns an I/O error if writing to `w` fails.
25    fn write_record(
26        &self,
27        w: &mut dyn Write,
28        system: Option<&str>,
29        timestamp: &str,
30        record: &WideEventRecord,
31    ) -> io::Result<()>;
32}
33
34/// Formats wide events as single-line JSON objects.
35pub struct JsonFormatter;
36
37impl WideEventFormatter for JsonFormatter {
38    fn write_record(
39        &self,
40        w: &mut dyn Write,
41        system: Option<&str>,
42        timestamp: &str,
43        record: &WideEventRecord,
44    ) -> io::Result<()> {
45        #[derive(Serialize)]
46        struct Output<'a> {
47            #[serde(skip_serializing_if = "Option::is_none")]
48            system: Option<&'a str>,
49            subsystem: &'a str,
50            timestamp: &'a str,
51            duration_ns: f64,
52            #[serde(skip_serializing_if = "Option::is_none")]
53            trace_id: &'a Option<String>,
54            #[serde(skip_serializing_if = "Option::is_none")]
55            span_id: &'a Option<String>,
56            #[serde(flatten)]
57            fields: &'a HashMap<&'static str, Value>,
58        }
59
60        let output = Output {
61            system,
62            subsystem: record.subsystem,
63            timestamp,
64            #[allow(
65                clippy::cast_precision_loss,
66                reason = "acceptable for duration display"
67            )]
68            duration_ns: record.duration.as_nanos() as f64,
69            trace_id: &record.trace_id,
70            span_id: &record.span_id,
71            fields: &record.fields,
72        };
73
74        serde_json::to_writer(w, &output).map_err(io::Error::other)
75    }
76}
77
78/// Formats wide events as logfmt (`key=value` pairs).
79pub struct LogfmtFormatter;
80
81impl WideEventFormatter for LogfmtFormatter {
82    fn write_record(
83        &self,
84        w: &mut dyn Write,
85        system: Option<&str>,
86        timestamp: &str,
87        record: &WideEventRecord,
88    ) -> io::Result<()> {
89        let mut first = true;
90        let mut pair = |w: &mut dyn Write, key: &str, val: &str| -> io::Result<()> {
91            if !first {
92                write!(w, " ")?;
93            }
94            first = false;
95            if val.contains(' ') || val.contains('"') || val.contains('=') || val.is_empty() {
96                write!(
97                    w,
98                    "{key}=\"{}\"",
99                    val.replace('\\', "\\\\").replace('"', "\\\"")
100                )
101            } else {
102                write!(w, "{key}={val}")
103            }
104        };
105
106        if let Some(system) = system {
107            pair(w, "system", system)?;
108        }
109        pair(w, "subsystem", record.subsystem)?;
110        pair(w, "timestamp", timestamp)?;
111        #[allow(
112            clippy::cast_precision_loss,
113            reason = "acceptable for duration display"
114        )]
115        let duration_ns = record.duration.as_nanos() as f64;
116        pair(w, "duration_ns", &duration_ns.to_string())?;
117
118        if let Some(ref tid) = record.trace_id {
119            pair(w, "trace_id", tid)?;
120        }
121        if let Some(ref sid) = record.span_id {
122            pair(w, "span_id", sid)?;
123        }
124
125        for (k, v) in &record.fields {
126            let val_str = match v {
127                Value::String(s) => s.as_str().into(),
128                Value::Number(n) => n.to_string(),
129                Value::Bool(b) => b.to_string(),
130                Value::Null => "null".into(),
131                other => serde_json::to_string(other).unwrap_or_default(),
132            };
133            pair(w, k, &val_str)?;
134        }
135
136        Ok(())
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    fn test_record() -> WideEventRecord {
145        let mut fields = HashMap::new();
146        fields.insert("method", Value::String("GET".into()));
147        fields.insert("status", Value::Number(200.into()));
148
149        WideEventRecord {
150            subsystem: "http",
151            duration: std::time::Duration::from_nanos(1_500_000),
152            fields,
153            trace_id: None,
154            span_id: None,
155        }
156    }
157
158    #[test]
159    fn json_basic() {
160        let mut buf = Vec::new();
161        JsonFormatter
162            .write_record(
163                &mut buf,
164                Some("myapp"),
165                "2024-01-15T14:30:00Z",
166                &test_record(),
167            )
168            .unwrap();
169
170        let parsed: serde_json::Value = serde_json::from_slice(&buf).unwrap();
171        assert_eq!(parsed["system"], "myapp");
172        assert_eq!(parsed["subsystem"], "http");
173        assert_eq!(parsed["timestamp"], "2024-01-15T14:30:00Z");
174        assert_eq!(parsed["method"], "GET");
175        assert_eq!(parsed["status"], 200);
176        assert_eq!(parsed["duration_ns"], 1_500_000.0);
177    }
178
179    #[test]
180    fn json_no_system() {
181        let mut buf = Vec::new();
182        JsonFormatter
183            .write_record(&mut buf, None, "2024-01-15T14:30:00Z", &test_record())
184            .unwrap();
185
186        let parsed: serde_json::Value = serde_json::from_slice(&buf).unwrap();
187        assert!(parsed.get("system").is_none());
188    }
189
190    #[test]
191    fn json_with_otel() {
192        let mut rec = test_record();
193        rec.trace_id = Some("abc123".into());
194        rec.span_id = Some("def456".into());
195
196        let mut buf = Vec::new();
197        JsonFormatter
198            .write_record(&mut buf, None, "2024-01-15T14:30:00Z", &rec)
199            .unwrap();
200
201        let parsed: serde_json::Value = serde_json::from_slice(&buf).unwrap();
202        assert_eq!(parsed["trace_id"], "abc123");
203        assert_eq!(parsed["span_id"], "def456");
204    }
205
206    #[test]
207    fn logfmt_basic() {
208        let mut buf = Vec::new();
209        LogfmtFormatter
210            .write_record(
211                &mut buf,
212                Some("myapp"),
213                "2024-01-15T14:30:00Z",
214                &test_record(),
215            )
216            .unwrap();
217
218        let output = String::from_utf8(buf).unwrap();
219        assert!(output.contains("system=myapp"));
220        assert!(output.contains("subsystem=http"));
221        assert!(output.contains("timestamp=2024-01-15T14:30:00Z"));
222        assert!(output.contains("duration_ns=1500000"));
223    }
224
225    #[test]
226    fn logfmt_quotes_spaces() {
227        let mut rec = test_record();
228        rec.fields
229            .insert("msg", Value::String("hello world".into()));
230
231        let mut buf = Vec::new();
232        LogfmtFormatter
233            .write_record(&mut buf, None, "now", &rec)
234            .unwrap();
235
236        let output = String::from_utf8(buf).unwrap();
237        assert!(output.contains("msg=\"hello world\""));
238    }
239}