Skip to main content

rlg/
log.rs

1// log.rs
2// Copyright © 2024-2026 RustLogs (RLG). All rights reserved.
3// SPDX-License-Identifier: Apache-2.0
4// SPDX-License-Identifier: MIT
5
6use crate::datetime;
7use crate::{LogFormat, LogLevel};
8use serde::{Deserialize, Serialize};
9use std::borrow::Cow;
10use std::collections::BTreeMap;
11use std::fmt;
12use std::sync::LazyLock;
13use std::sync::atomic::{AtomicU64, Ordering};
14
15/// Monotonic session ID counter. Incremented atomically per `build()` call.
16static SESSION_COUNTER: AtomicU64 = AtomicU64::new(1);
17
18/// Hostname, resolved once and cached for the process lifetime.
19static CACHED_HOSTNAME: LazyLock<String> =
20    LazyLock::new(|| resolve_hostname(hostname::get()));
21
22/// Pure helper for [`CACHED_HOSTNAME`] — exposed so the `"localhost"`
23/// fallback branch can be unit-tested without injecting a syscall failure.
24fn resolve_hostname(
25    raw: std::io::Result<std::ffi::OsString>,
26) -> String {
27    raw.map_or_else(
28        |_| "localhost".to_string(),
29        |h| h.to_string_lossy().to_string(),
30    )
31}
32
33/// A structured log entry with a chainable builder API.
34///
35/// Fields use `Cow<'static, str>` and `u64` where possible to
36/// minimize heap allocations on the ingestion hot path.
37///
38/// Construct via level shortcuts ([`Log::info`], [`Log::error`], ...)
39/// or the generic [`Log::build`]. Dispatch with [`Log::fire`].
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Eq)]
41pub struct Log {
42    /// Monotonic counter assigned at `build()` time.
43    pub session_id: u64,
44    /// Wall-clock timestamp. Populated at build time; override with `.time()`.
45    pub time: Cow<'static, str>,
46    /// Severity level (`INFO`, `ERROR`, etc.).
47    pub level: LogLevel,
48    /// Originating service or module name. Defaults to `"default"`.
49    pub component: Cow<'static, str>,
50    /// Human-readable message body.
51    pub description: String,
52    /// Output format applied during `Display` serialization.
53    pub format: LogFormat,
54    /// Arbitrary key-value attributes for structured context.
55    pub attributes: BTreeMap<String, serde_json::Value>,
56}
57
58impl Default for Log {
59    fn default() -> Self {
60        Self {
61            session_id: 0,
62            time: Cow::Borrowed(""),
63            level: LogLevel::INFO,
64            component: Cow::Borrowed(""),
65            description: String::default(),
66            format: LogFormat::CLF,
67            attributes: BTreeMap::new(),
68        }
69    }
70}
71
72impl Log {
73    /// Ingest this entry into the engine by cloning it.
74    ///
75    /// **Prefer [`fire()`](Self::fire)**, which consumes `self` and avoids
76    /// the clone. Use `log()` only when you need to retain the entry.
77    #[track_caller]
78    pub fn log(&self) {
79        crate::engine::ENGINE.inc_format(self.format);
80        let event = crate::engine::LogEvent {
81            level: self.level,
82            level_num: self.level.to_numeric(),
83            log: self.clone(),
84        };
85        crate::engine::ENGINE.ingest(event);
86    }
87
88    /// Build an INFO-level log entry.
89    #[must_use]
90    pub fn info(description: &str) -> Self {
91        Self::build(LogLevel::INFO, description)
92    }
93
94    /// Build a WARN-level log entry.
95    #[must_use]
96    pub fn warn(description: &str) -> Self {
97        Self::build(LogLevel::WARN, description)
98    }
99
100    /// Build an ERROR-level log entry.
101    #[must_use]
102    pub fn error(description: &str) -> Self {
103        Self::build(LogLevel::ERROR, description)
104    }
105
106    /// Build a DEBUG-level log entry.
107    #[must_use]
108    pub fn debug(description: &str) -> Self {
109        Self::build(LogLevel::DEBUG, description)
110    }
111
112    /// Build a TRACE-level log entry.
113    #[must_use]
114    pub fn trace(description: &str) -> Self {
115        Self::build(LogLevel::TRACE, description)
116    }
117
118    /// Build a VERBOSE-level log entry.
119    #[must_use]
120    pub fn verbose(description: &str) -> Self {
121        Self::build(LogLevel::VERBOSE, description)
122    }
123
124    /// Build a FATAL-level log entry.
125    #[must_use]
126    pub fn fatal(description: &str) -> Self {
127        Self::build(LogLevel::FATAL, description)
128    }
129
130    /// Build a CRITICAL-level log entry.
131    #[must_use]
132    pub fn critical(description: &str) -> Self {
133        Self::build(LogLevel::CRITICAL, description)
134    }
135
136    /// Build a log entry with an explicit level and description.
137    ///
138    /// Assigns a monotonic `session_id` and captures the current wall-clock
139    /// time. Defaults to `LogFormat::MCP` and component `"default"`.
140    #[must_use]
141    pub fn build(level: LogLevel, description: &str) -> Self {
142        Self {
143            session_id: SESSION_COUNTER.fetch_add(1, Ordering::Relaxed),
144            time: Cow::Owned(datetime::now_iso8601()),
145            level,
146            component: Cow::Borrowed("default"),
147            description: description.to_string(),
148            format: LogFormat::MCP,
149            attributes: BTreeMap::new(),
150        }
151    }
152
153    /// Override the timestamp for this entry.
154    #[must_use]
155    pub fn time(mut self, time: &str) -> Self {
156        self.time = Cow::Owned(time.to_string());
157        self
158    }
159
160    /// Override the auto-assigned session ID.
161    #[must_use]
162    pub const fn session_id(mut self, session_id: u64) -> Self {
163        self.session_id = session_id;
164        self
165    }
166
167    /// Attach a key-value attribute. Accepts any `T: Serialize`.
168    #[must_use]
169    pub fn with<T: Serialize>(mut self, key: &str, value: T) -> Self {
170        if let Ok(val) = serde_json::to_value(value) {
171            self.attributes.insert(key.to_string(), val);
172        }
173        self
174    }
175
176    /// Tag the originating service or module.
177    #[must_use]
178    pub fn component(mut self, component: &str) -> Self {
179        self.component = Cow::Owned(component.to_string());
180        self
181    }
182
183    /// Set the output format for this entry.
184    #[must_use]
185    pub const fn format(mut self, format: LogFormat) -> Self {
186        self.format = format;
187        self
188    }
189
190    /// Consume this entry and push it into the ring buffer.
191    ///
192    /// Cost: one `Log` move (~128 bytes). Serialization is deferred.
193    /// Automatically captures `file:line` via `#[track_caller]`.
194    #[track_caller]
195    pub fn fire(mut self) {
196        let caller = std::panic::Location::caller();
197        self.attributes.insert(
198            "caller".to_string(),
199            serde_json::Value::String(format!(
200                "{}:{}",
201                caller.file(),
202                caller.line()
203            )),
204        );
205        crate::engine::ENGINE.inc_format(self.format);
206        let event = crate::engine::LogEvent {
207            level: self.level,
208            level_num: self.level.to_numeric(),
209            log: self,
210        };
211        crate::engine::ENGINE.ingest(event);
212    }
213
214    fn write_logfmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215        f.write_str("level=")?;
216        f.write_str(self.level.as_str_lowercase())?;
217        f.write_str(" msg=\"")?;
218        f.write_str(&self.description.replace('"', "\\\""))?;
219        write!(f, "\" session_id={}", self.session_id)?;
220        f.write_str(" component=\"")?;
221        f.write_str(&self.component)?;
222        f.write_str("\"")?;
223
224        for (key, value) in &self.attributes {
225            write!(f, " {key}=")?;
226            match value {
227                serde_json::Value::String(s) => {
228                    if s.contains(' ')
229                        || s.contains('"')
230                        || s.is_empty()
231                    {
232                        write!(f, "\"{0}\"", s.replace('"', "\\\""))?;
233                    } else {
234                        write!(f, "{s}")?;
235                    }
236                }
237                _ => write!(f, "{value}")?,
238            }
239        }
240        Ok(())
241    }
242}
243
244/// Writes a JSON-escaped string (with surrounding quotes) to the formatter.
245fn write_json_str(f: &mut fmt::Formatter<'_>, s: &str) -> fmt::Result {
246    f.write_str("\"")?;
247    for c in s.chars() {
248        match c {
249            '"' => f.write_str("\\\"")?,
250            '\\' => f.write_str("\\\\")?,
251            '\n' => f.write_str("\\n")?,
252            '\r' => f.write_str("\\r")?,
253            '\t' => f.write_str("\\t")?,
254            c if c.is_control() => write!(f, "\\u{:04x}", c as u32)?,
255            c => write!(f, "{c}")?,
256        }
257    }
258    f.write_str("\"")
259}
260
261/// Writes a `BTreeMap<String, serde_json::Value>` as a JSON object.
262fn write_json_map(
263    f: &mut fmt::Formatter<'_>,
264    map: &BTreeMap<String, serde_json::Value>,
265) -> fmt::Result {
266    f.write_str("{")?;
267    let mut first = true;
268    for (key, value) in map {
269        if !first {
270            f.write_str(",")?;
271        }
272        first = false;
273        write_json_str(f, key)?;
274        // serde_json::Value Display already produces valid JSON
275        write!(f, ":{value}")?;
276    }
277    f.write_str("}")
278}
279
280// --- Per-format serialization methods ---
281impl Log {
282    fn fmt_clf(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
283        write!(
284            f,
285            "SessionID={} Timestamp={} Description={} Level={} Component={}",
286            self.session_id,
287            self.time,
288            self.description,
289            self.level,
290            self.component
291        )
292    }
293
294    fn fmt_cef(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
295        write!(
296            f,
297            "CEF:0|{}|{}|{}|{}|{}|CEF",
298            self.session_id,
299            self.time,
300            self.level,
301            self.component,
302            self.description
303        )
304    }
305
306    fn fmt_elf(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
307        write!(
308            f,
309            "ELF:0|{}|{}|{}|{}|{}|ELF",
310            self.session_id,
311            self.time,
312            self.level,
313            self.component,
314            self.description
315        )
316    }
317
318    fn fmt_w3c(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
319        write!(
320            f,
321            "W3C:0|{}|{}|{}|{}|{}|W3C",
322            self.session_id,
323            self.time,
324            self.level,
325            self.component,
326            self.description
327        )
328    }
329
330    fn fmt_apache(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
331        write!(
332            f,
333            "{} - - [{}] \"{}\" {} {}",
334            &*CACHED_HOSTNAME,
335            self.time,
336            self.description,
337            self.level,
338            self.component
339        )
340    }
341
342    fn fmt_log4j_xml(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
343        write!(
344            f,
345            r#"<log4j:event logger="{}" timestamp="{}" level="{}" thread="{}"><log4j:message>{}</log4j:message></log4j:event>"#,
346            self.component,
347            self.time,
348            self.level,
349            self.session_id,
350            self.description
351        )
352    }
353
354    fn fmt_json(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
355        f.write_str("{\"Attributes\":")?;
356        write_json_map(f, &self.attributes)?;
357        f.write_str(",\"Component\":")?;
358        write_json_str(f, &self.component)?;
359        f.write_str(",\"Description\":")?;
360        write_json_str(f, &self.description)?;
361        f.write_str(",\"Format\":\"JSON\",\"Level\":")?;
362        write_json_str(f, self.level.as_str())?;
363        write!(f, ",\"SessionID\":{}", self.session_id)?;
364        f.write_str(",\"Timestamp\":")?;
365        write_json_str(f, &self.time)?;
366        f.write_str("}")
367    }
368
369    fn fmt_gelf(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
370        f.write_str("{\"_attributes\":")?;
371        write_json_map(f, &self.attributes)?;
372        write!(f, ",\"_session_id\":{}", self.session_id)?;
373        f.write_str(",\"full_message\":")?;
374        write_json_str(f, &self.description)?;
375        f.write_str(",\"host\":")?;
376        write_json_str(f, &self.component)?;
377        write!(f, ",\"level\":{}", self.level.to_numeric())?;
378        f.write_str(",\"short_message\":")?;
379        write_json_str(f, &self.description)?;
380        f.write_str(",\"timestamp\":")?;
381        write_json_str(f, &self.time)?;
382        f.write_str(",\"version\":\"1.1\"}")
383    }
384
385    fn fmt_logstash(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
386        f.write_str("{\"@timestamp\":")?;
387        write_json_str(f, &self.time)?;
388        f.write_str(",\"attributes\":")?;
389        write_json_map(f, &self.attributes)?;
390        f.write_str(",\"component\":")?;
391        write_json_str(f, &self.component)?;
392        f.write_str(",\"level\":")?;
393        write_json_str(f, self.level.as_str())?;
394        f.write_str(",\"message\":")?;
395        write_json_str(f, &self.description)?;
396        write!(f, ",\"session_id\":{}", self.session_id)?;
397        f.write_str("}")
398    }
399
400    fn fmt_ndjson(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
401        f.write_str("{\"attributes\":")?;
402        write_json_map(f, &self.attributes)?;
403        f.write_str(",\"component\":")?;
404        write_json_str(f, &self.component)?;
405        f.write_str(",\"level\":")?;
406        write_json_str(f, self.level.as_str())?;
407        f.write_str(",\"message\":")?;
408        write_json_str(f, &self.description)?;
409        f.write_str(",\"timestamp\":")?;
410        write_json_str(f, &self.time)?;
411        f.write_str("}")
412    }
413
414    fn fmt_mcp(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
415        f.write_str("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/log\",\"params\":{\"data\":{\"attributes\":")?;
416        write_json_map(f, &self.attributes)?;
417        f.write_str(",\"component\":")?;
418        write_json_str(f, &self.component)?;
419        f.write_str(",\"description\":")?;
420        write_json_str(f, &self.description)?;
421        write!(f, ",\"session_id\":{}", self.session_id)?;
422        f.write_str(",\"time\":")?;
423        write_json_str(f, &self.time)?;
424        f.write_str("},\"level\":")?;
425        write_json_str(f, self.level.as_str_lowercase())?;
426        f.write_str("}}")
427    }
428
429    fn fmt_otlp(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
430        let empty = serde_json::Value::String(String::new());
431        let trace_id =
432            self.attributes.get("trace_id").unwrap_or(&empty);
433        let span_id = self.attributes.get("span_id").unwrap_or(&empty);
434        f.write_str("{\"attributes\":")?;
435        write_json_map(f, &self.attributes)?;
436        f.write_str(",\"body\":{\"stringValue\":")?;
437        write_json_str(f, &self.description)?;
438        write!(f, "}},\"severityNumber\":{}", self.level.to_numeric())?;
439        f.write_str(",\"severityText\":")?;
440        write_json_str(f, self.level.as_str())?;
441        write!(f, ",\"spanId\":{span_id}")?;
442        f.write_str(",\"timeUnixNano\":")?;
443        write_json_str(f, &self.time)?;
444        write!(f, ",\"traceId\":{trace_id}}}")
445    }
446
447    fn fmt_ecs(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
448        f.write_str("{\"@timestamp\":")?;
449        write_json_str(f, &self.time)?;
450        f.write_str(",\"labels\":")?;
451        write_json_map(f, &self.attributes)?;
452        f.write_str(",\"log.level\":")?;
453        write_json_str(f, self.level.as_str_lowercase())?;
454        f.write_str(",\"log.logger\":\"rlg\",\"message\":")?;
455        write_json_str(f, &self.description)?;
456        f.write_str(",\"process.name\":")?;
457        write_json_str(f, &self.component)?;
458        f.write_str("}")
459    }
460}
461
462impl fmt::Display for Log {
463    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
464        match self.format {
465            LogFormat::CLF => self.fmt_clf(f),
466            LogFormat::CEF => self.fmt_cef(f),
467            LogFormat::ELF => self.fmt_elf(f),
468            LogFormat::W3C => self.fmt_w3c(f),
469            LogFormat::ApacheAccessLog => self.fmt_apache(f),
470            LogFormat::Log4jXML => self.fmt_log4j_xml(f),
471            LogFormat::JSON => self.fmt_json(f),
472            LogFormat::GELF => self.fmt_gelf(f),
473            LogFormat::Logstash => self.fmt_logstash(f),
474            LogFormat::NDJSON => self.fmt_ndjson(f),
475            LogFormat::MCP => self.fmt_mcp(f),
476            LogFormat::OTLP => self.fmt_otlp(f),
477            LogFormat::Logfmt => self.write_logfmt(f),
478            LogFormat::ECS => self.fmt_ecs(f),
479        }
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486    use crate::log_format::LogFormat;
487
488    #[test]
489    fn resolve_hostname_uses_localhost_on_error() {
490        let err = Err(std::io::Error::other("no hostname"));
491        assert_eq!(resolve_hostname(err), "localhost");
492    }
493
494    #[test]
495    fn resolve_hostname_returns_provided_value() {
496        let raw = Ok(std::ffi::OsString::from("test-host"));
497        assert_eq!(resolve_hostname(raw), "test-host");
498    }
499
500    #[test]
501    #[cfg_attr(miri, ignore)]
502    fn test_log_write_logfmt_with_attributes() {
503        let mut log = Log::build(LogLevel::INFO, "desc")
504            .session_id(99)
505            .time("ts")
506            .component("comp")
507            .format(LogFormat::Logfmt);
508        log.attributes
509            .insert("key".to_string(), serde_json::json!("value"));
510        log.attributes.insert(
511            "space".to_string(),
512            serde_json::json!("has space"),
513        );
514        log.attributes
515            .insert("num".to_string(), serde_json::json!(42));
516        log.attributes
517            .insert("empty".to_string(), serde_json::json!(""));
518
519        let output = format!("{log}");
520        assert!(output.contains("key=value"));
521        assert!(output.contains("space=\"has space\""));
522        assert!(output.contains("num=42"));
523        assert!(output.contains("empty=\"\""));
524
525        // Case with no attributes to cover the other branch
526        let log_no_attr = Log::build(LogLevel::INFO, "desc")
527            .session_id(100)
528            .time("ts")
529            .component("comp")
530            .format(LogFormat::Logfmt);
531        let output_no = format!("{log_no_attr}");
532        assert!(!output_no.contains(" key="));
533    }
534}