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