Skip to main content

cordis/
logger.rs

1//! Structured logger facade, bounded buffer, formatting, and exporters.
2
3use crate::context::Context;
4use crate::effect::{AsyncDisposer, EffectHandle};
5use crate::utils::lock;
6use crate::{Result, Value};
7use std::collections::{BTreeMap, HashMap, VecDeque};
8use std::fmt::{self, Debug, Formatter};
9use std::sync::{Arc, Mutex};
10use std::time::{SystemTime, UNIX_EPOCH};
11
12/// Logger severity/method name.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub enum LoggerType {
15    /// Error message.
16    Error,
17    /// Informational message.
18    Info,
19    /// Warning message.
20    Warn,
21    /// Debug message.
22    Debug,
23}
24
25/// Numeric exporter threshold, matching Cordis ordering.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
27#[repr(u8)]
28pub enum LoggerLevel {
29    /// Error only.
30    Error = 0,
31    /// Errors and info.
32    Info = 1,
33    /// Errors, info, and warnings.
34    Warn = 2,
35    /// Every built-in level.
36    Debug = 3,
37}
38
39impl LoggerType {
40    fn level(self) -> LoggerLevel {
41        match self {
42            Self::Error => LoggerLevel::Error,
43            Self::Info => LoggerLevel::Info,
44            Self::Warn => LoggerLevel::Warn,
45            Self::Debug => LoggerLevel::Debug,
46        }
47    }
48}
49
50/// Dynamically typed but formatting-friendly log argument.
51#[derive(Debug, Clone)]
52pub enum LogArg {
53    /// UTF-8 string.
54    String(String),
55    /// Signed integer.
56    Integer(i64),
57    /// Unsigned integer.
58    Unsigned(u64),
59    /// Floating point number.
60    Float(f64),
61    /// Boolean.
62    Bool(bool),
63    /// Pre-rendered debug/object representation.
64    Object(String),
65}
66
67impl LogArg {
68    fn string(&self) -> String {
69        match self {
70            Self::String(value) | Self::Object(value) => value.clone(),
71            Self::Integer(value) => value.to_string(),
72            Self::Unsigned(value) => value.to_string(),
73            Self::Float(value) => value.to_string(),
74            Self::Bool(value) => value.to_string(),
75        }
76    }
77
78    fn integer(&self) -> String {
79        match self {
80            Self::Integer(value) => value.to_string(),
81            Self::Unsigned(value) => value.to_string(),
82            Self::Float(value) => (*value as i64).to_string(),
83            Self::Bool(value) => (if *value { 1_i64 } else { 0_i64 }).to_string(),
84            Self::String(value) | Self::Object(value) => {
85                value.parse::<i64>().unwrap_or_default().to_string()
86            }
87        }
88    }
89
90    fn float(&self) -> String {
91        match self {
92            Self::Float(value) => value.to_string(),
93            Self::Integer(value) => (*value as f64).to_string(),
94            Self::Unsigned(value) => (*value as f64).to_string(),
95            Self::Bool(value) => (if *value { 1.0 } else { 0.0 }).to_string(),
96            Self::String(value) | Self::Object(value) => {
97                value.parse::<f64>().unwrap_or_default().to_string()
98            }
99        }
100    }
101}
102
103impl From<String> for LogArg {
104    fn from(value: String) -> Self {
105        Self::String(value)
106    }
107}
108
109impl From<&str> for LogArg {
110    fn from(value: &str) -> Self {
111        Self::String(value.to_owned())
112    }
113}
114
115macro_rules! integer_log_arg {
116    ($($ty:ty),* $(,)?) => {$ (
117        impl From<$ty> for LogArg {
118            fn from(value: $ty) -> Self { Self::Integer(value as i64) }
119        }
120    )* };
121}
122integer_log_arg!(i8, i16, i32, i64, isize);
123
124macro_rules! unsigned_log_arg {
125    ($($ty:ty),* $(,)?) => {$ (
126        impl From<$ty> for LogArg {
127            fn from(value: $ty) -> Self { Self::Unsigned(value as u64) }
128        }
129    )* };
130}
131unsigned_log_arg!(u8, u16, u32, u64, usize);
132
133impl From<f32> for LogArg {
134    fn from(value: f32) -> Self {
135        Self::Float(value.into())
136    }
137}
138
139impl From<f64> for LogArg {
140    fn from(value: f64) -> Self {
141        Self::Float(value)
142    }
143}
144
145impl From<bool> for LogArg {
146    fn from(value: bool) -> Self {
147        Self::Bool(value)
148    }
149}
150
151impl From<Value> for LogArg {
152    fn from(value: Value) -> Self {
153        Self::Object(format!("{value:?}"))
154    }
155}
156
157/// Structured log record delivered to exporters.
158#[derive(Debug, Clone)]
159pub struct Message {
160    /// Monotonic sequence number.
161    pub sequence: u64,
162    /// Unix timestamp in milliseconds.
163    pub timestamp: u64,
164    /// Logger name.
165    pub name: String,
166    /// Severity category.
167    pub kind: LoggerType,
168    /// Numeric severity.
169    pub level: LoggerLevel,
170    /// Printf-style format plus values.
171    pub args: Vec<LogArg>,
172    /// Originating fiber id.
173    pub fiber_uid: Option<u64>,
174    /// Originating fiber display name.
175    pub fiber_name: String,
176}
177
178/// Custom placeholder formatter.
179pub type FormatterFn =
180    Arc<dyn Fn(&LogArg, &ExporterConfig, &Message) -> String + Send + Sync + 'static>;
181
182/// Exporter formatting and level configuration.
183#[derive(Clone)]
184pub struct ExporterConfig {
185    /// ANSI color capability (`0` disables, `2+` enables decorations).
186    pub colors: u8,
187    /// Maximum Unicode scalar count for each rendered line.
188    pub max_length: usize,
189    /// Logger-specific thresholds. The `default` key is the fallback.
190    pub levels: HashMap<String, LoggerLevel>,
191    /// Additional or overriding printf placeholder formatters.
192    pub formatters: HashMap<char, FormatterFn>,
193}
194
195impl Default for ExporterConfig {
196    fn default() -> Self {
197        Self {
198            colors: 0,
199            max_length: 10_240,
200            levels: HashMap::new(),
201            formatters: HashMap::new(),
202        }
203    }
204}
205
206impl Debug for ExporterConfig {
207    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
208        f.debug_struct("ExporterConfig")
209            .field("colors", &self.colors)
210            .field("max_length", &self.max_length)
211            .field("levels", &self.levels)
212            .field("formatters", &self.formatters.keys().collect::<Vec<_>>())
213            .finish()
214    }
215}
216
217/// Structured log sink.
218pub trait Exporter: Send + Sync + 'static {
219    /// Exporter-specific formatting and filtering.
220    fn config(&self) -> ExporterConfig {
221        ExporterConfig::default()
222    }
223
224    /// Borrow the config without cloning, when the exporter stores it.
225    ///
226    /// The logger hot path prefers this over [`config`](Self::config), which
227    /// deep-clones the level and formatter maps for closure exporters.
228    fn config_ref(&self) -> Option<&ExporterConfig> {
229        None
230    }
231
232    /// Receive one message.
233    fn export(&self, message: &Message);
234}
235
236struct ClosureExporter<F> {
237    config: ExporterConfig,
238    callback: F,
239}
240
241impl<F> Exporter for ClosureExporter<F>
242where
243    F: Fn(&Message) + Send + Sync + 'static,
244{
245    fn config(&self) -> ExporterConfig {
246        self.config.clone()
247    }
248
249    fn config_ref(&self) -> Option<&ExporterConfig> {
250        Some(&self.config)
251    }
252
253    fn export(&self, message: &Message) {
254        (self.callback)(message)
255    }
256}
257
258/// Logger intercept config resolved from `ctx.intercept("logger", ...)`.
259#[derive(Debug, Clone, Default)]
260pub struct LoggerIntercept {
261    /// Override derived logger name.
262    pub name: Option<String>,
263    /// Override default level.
264    pub level: Option<LoggerLevel>,
265}
266
267struct LoggerState {
268    sequence: u64,
269    next_exporter: u64,
270    buffer_size: usize,
271    buffer: VecDeque<Arc<Message>>,
272    exporters: BTreeMap<u64, Arc<dyn Exporter>>,
273    exporter_snapshot: Arc<Vec<Arc<dyn Exporter>>>,
274}
275
276fn refresh_exporter_snapshot(state: &mut LoggerState) {
277    state.exporter_snapshot = Arc::new(state.exporters.values().cloned().collect());
278}
279
280pub(crate) struct LoggerRoot {
281    state: Mutex<LoggerState>,
282}
283
284impl LoggerRoot {
285    pub(crate) fn new() -> Self {
286        Self {
287            state: Mutex::new(LoggerState {
288                sequence: 0,
289                next_exporter: 0,
290                buffer_size: 1_000,
291                buffer: VecDeque::new(),
292                exporters: BTreeMap::new(),
293                exporter_snapshot: Arc::new(Vec::new()),
294            }),
295        }
296    }
297
298    fn send(
299        &self,
300        name: String,
301        default_level: Option<LoggerLevel>,
302        kind: LoggerType,
303        args: Vec<LogArg>,
304        fiber_uid: Option<u64>,
305        fiber_name: String,
306    ) {
307        let (message, exporters) = {
308            let mut state = lock(&self.state);
309            state.sequence += 1;
310            let timestamp = SystemTime::now()
311                .duration_since(UNIX_EPOCH)
312                .unwrap_or_default()
313                .as_millis()
314                .min(u64::MAX as u128) as u64;
315            let message = Message {
316                sequence: state.sequence,
317                timestamp,
318                name,
319                kind,
320                level: kind.level(),
321                args,
322                fiber_uid,
323                fiber_name,
324            };
325            let buffer_threshold = default_level.unwrap_or(LoggerLevel::Info);
326            let message = Arc::new(message);
327            if buffer_threshold >= message.level && state.buffer_size > 0 {
328                state.buffer.push_back(message.clone());
329                while state.buffer.len() > state.buffer_size {
330                    state.buffer.pop_front();
331                }
332            } else if state.buffer_size == 0 {
333                state.buffer.clear();
334            }
335            (message, state.exporter_snapshot.clone())
336        };
337
338        for exporter in exporters.iter() {
339            let owned_config;
340            let config = match exporter.config_ref() {
341                Some(config) => config,
342                None => {
343                    owned_config = exporter.config();
344                    &owned_config
345                }
346            };
347            let threshold = config
348                .levels
349                .get(&message.name)
350                .or_else(|| config.levels.get("default"))
351                .copied()
352                .or(default_level)
353                .unwrap_or(LoggerLevel::Info);
354            if threshold >= message.level {
355                exporter.export(&message);
356            }
357        }
358    }
359}
360
361/// ANSI 16-color palette indexes used for logger name coloring.
362pub const C16: &[u8] = &[6, 2, 3, 4, 5, 1];
363/// ANSI 256-color palette indexes used for logger name coloring.
364pub const C256: &[u8] = &[
365    20, 21, 26, 27, 32, 33, 38, 39, 40, 41, 42, 43, 44, 45, 56, 57, 62, 63, 68, 69, 74, 75, 76, 77,
366    78, 79, 80, 81, 92, 93, 98, 99, 112, 113, 129, 134, 135, 148, 149, 160, 161, 162, 163, 164,
367    165, 166, 167, 168, 169, 170, 171, 172, 173, 178, 179, 184, 185, 196, 197, 198, 199, 200, 201,
368    202, 203, 204, 205, 206, 207, 208, 209, 214, 215, 220, 221,
369];
370
371/// Stable logger-name color hash.
372pub fn color_code(name: &str, colors: u8) -> u8 {
373    let mut hash: i32 = 0;
374    for byte in name.bytes() {
375        hash = hash
376            .wrapping_shl(3)
377            .wrapping_sub(hash)
378            .wrapping_add(i32::from(byte))
379            .wrapping_add(13);
380    }
381    let palette = if colors == 0 {
382        return 0;
383    } else if colors >= 2 {
384        C256
385    } else {
386        C16
387    };
388    palette[(hash.unsigned_abs() as usize) % palette.len()]
389}
390
391fn color(config: &ExporterConfig, code: u8, value: String) -> String {
392    if config.colors == 0 {
393        value
394    } else if code < 8 {
395        format!("\u{001b}[3{code}m{value}\u{001b}[0m")
396    } else {
397        format!("\u{001b}[38;5;{code}m{value}\u{001b}[0m")
398    }
399}
400
401/// Format a message using Cordis printf placeholders.
402pub fn default_format(config: &ExporterConfig, message: &Message) -> String {
403    let mut values = message.args.iter();
404    let format = match values.next() {
405        Some(LogArg::String(value)) => value.clone(),
406        Some(value) => {
407            let mut output = value.string();
408            for value in values {
409                output.push(' ');
410                output.push_str(&value.string());
411            }
412            return truncate_lines(output, config.max_length);
413        }
414        None => String::new(),
415    };
416
417    let remaining = values.cloned().collect::<Vec<_>>();
418    let mut next_value = 0;
419    let mut chars = format.chars().peekable();
420    let mut output = String::new();
421    while let Some(character) = chars.next() {
422        if character != '%' {
423            output.push(character);
424            continue;
425        }
426        let Some(placeholder) = chars.next() else {
427            output.push('%');
428            break;
429        };
430        if placeholder == '%' {
431            output.push('%');
432            continue;
433        }
434        let Some(value) = remaining.get(next_value) else {
435            output.push('%');
436            output.push(placeholder);
437            continue;
438        };
439        if let Some(formatter) = config.formatters.get(&placeholder) {
440            output.push_str(&formatter(value, config, message));
441            next_value += 1;
442            continue;
443        }
444        let rendered = match placeholder {
445            's' | 'o' | 'O' => Some(value.string()),
446            'd' | 'i' => Some(value.integer()),
447            'f' => Some(value.float()),
448            'c' => Some(String::new()),
449            'C' => Some(color(
450                config,
451                color_code(&message.name, config.colors),
452                value.string(),
453            )),
454            _ => None,
455        };
456        if let Some(rendered) = rendered {
457            output.push_str(&rendered);
458            next_value += 1;
459        } else {
460            output.push('%');
461            output.push(placeholder);
462        }
463    }
464    for value in &remaining[next_value..] {
465        output.push(' ');
466        output.push_str(&value.string());
467    }
468    truncate_lines(output, config.max_length)
469}
470
471fn truncate_lines(value: String, max_length: usize) -> String {
472    value
473        .lines()
474        .map(|line| {
475            let mut chars = line.chars();
476            let head = chars.by_ref().take(max_length).collect::<String>();
477            if chars.next().is_some() {
478                head + "..."
479            } else {
480                head
481            }
482        })
483        .collect::<Vec<_>>()
484        .join("\n")
485}
486
487/// Named logger facade.
488#[derive(Clone)]
489pub struct Logger {
490    ctx: Context,
491    /// Logger name.
492    pub name: String,
493    /// Default threshold if an exporter supplies none.
494    pub level: Option<LoggerLevel>,
495}
496
497impl Logger {
498    fn write(&self, kind: LoggerType, format: impl Into<String>, args: Vec<LogArg>) {
499        let mut all = Vec::with_capacity(args.len() + 1);
500        all.push(LogArg::String(format.into()));
501        all.extend(args);
502        let fiber = self.ctx.fiber().ok();
503        self.ctx.root.logger.send(
504            self.name.clone(),
505            self.level,
506            kind,
507            all,
508            fiber.as_ref().and_then(|fiber| fiber.uid()),
509            fiber
510                .map(|fiber| fiber.name())
511                .unwrap_or_else(|| "disposed".to_owned()),
512        );
513    }
514
515    /// Log an error.
516    pub fn error(&self, format: impl Into<String>, args: impl IntoIterator<Item = LogArg>) {
517        self.write(LoggerType::Error, format, args.into_iter().collect());
518    }
519
520    /// Log an informational message.
521    pub fn info(&self, format: impl Into<String>, args: impl IntoIterator<Item = LogArg>) {
522        self.write(LoggerType::Info, format, args.into_iter().collect());
523    }
524
525    /// Log a warning.
526    pub fn warn(&self, format: impl Into<String>, args: impl IntoIterator<Item = LogArg>) {
527        self.write(LoggerType::Warn, format, args.into_iter().collect());
528    }
529
530    /// Log a debug message.
531    pub fn debug(&self, format: impl Into<String>, args: impl IntoIterator<Item = LogArg>) {
532        self.write(LoggerType::Debug, format, args.into_iter().collect());
533    }
534}
535
536impl Debug for Logger {
537    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
538        f.debug_struct("Logger")
539            .field("name", &self.name)
540            .field("level", &self.level)
541            .finish()
542    }
543}
544
545/// Built-in logging service bound to a context.
546#[derive(Clone, Debug)]
547pub struct LoggerService {
548    ctx: Context,
549}
550
551impl LoggerService {
552    pub(crate) fn new(ctx: Context) -> Self {
553        Self { ctx }
554    }
555
556    fn intercept(&self) -> LoggerIntercept {
557        let mut resolved = LoggerIntercept::default();
558        if let Ok(configs) = self.ctx.intercepts::<LoggerIntercept>("logger") {
559            for config in configs {
560                if config.name.is_some() {
561                    resolved.name = config.name.clone();
562                }
563                if config.level.is_some() {
564                    resolved.level = config.level;
565                }
566            }
567        }
568        resolved
569    }
570
571    /// Create a logger. An intercept name overrides the fiber-derived name;
572    /// an explicit name overrides both.
573    pub fn logger(&self, name: Option<String>) -> Logger {
574        let intercept = self.intercept();
575        let name = name
576            .or(intercept.name)
577            .or_else(|| self.ctx.fiber().ok().map(|fiber| hyphenate(&fiber.name())))
578            .unwrap_or_else(|| "root".to_owned());
579        Logger {
580            ctx: self.ctx.clone(),
581            name,
582            level: intercept.level,
583        }
584    }
585
586    /// Register an exporter owned by the current fiber.
587    pub fn exporter<E: Exporter>(&self, exporter: E) -> Result<EffectHandle> {
588        self.exporter_arc(Arc::new(exporter))
589    }
590
591    /// Register an exporter callback with explicit config.
592    pub fn exporter_fn<F>(&self, config: ExporterConfig, callback: F) -> Result<EffectHandle>
593    where
594        F: Fn(&Message) + Send + Sync + 'static,
595    {
596        self.exporter(ClosureExporter { config, callback })
597    }
598
599    /// Register an already shared exporter.
600    pub fn exporter_arc(&self, exporter: Arc<dyn Exporter>) -> Result<EffectHandle> {
601        let id = {
602            let mut state = lock(&self.ctx.root.logger.state);
603            state.next_exporter += 1;
604            let id = state.next_exporter;
605            state.exporters.insert(id, exporter);
606            refresh_exporter_snapshot(&mut state);
607            id
608        };
609        let root = Arc::downgrade(&self.ctx.root);
610        let effect = self.ctx.fiber()?.register_effect(
611            "ctx.logger.exporter()",
612            AsyncDisposer::from_sync(move || {
613                if let Some(root) = root.upgrade() {
614                    let mut state = lock(&root.logger.state);
615                    state.exporters.remove(&id);
616                    refresh_exporter_snapshot(&mut state);
617                }
618                Ok(())
619            }),
620        );
621        if effect.is_err() {
622            let mut state = lock(&self.ctx.root.logger.state);
623            state.exporters.remove(&id);
624            refresh_exporter_snapshot(&mut state);
625        }
626        effect
627    }
628
629    /// Snapshot the chronological bounded message buffer.
630    pub fn buffer(&self) -> Vec<Message> {
631        lock(&self.ctx.root.logger.state)
632            .buffer
633            .iter()
634            .map(|message| message.as_ref().clone())
635            .collect()
636    }
637
638    /// Set buffer capacity, immediately trimming oldest records.
639    pub fn set_buffer_size(&self, size: usize) {
640        let mut state = lock(&self.ctx.root.logger.state);
641        state.buffer_size = size;
642        while state.buffer.len() > size {
643            state.buffer.pop_front();
644        }
645    }
646
647    /// Current buffer capacity.
648    pub fn buffer_size(&self) -> usize {
649        lock(&self.ctx.root.logger.state).buffer_size
650    }
651
652    /// Number of registered exporters.
653    pub fn exporter_count(&self) -> usize {
654        lock(&self.ctx.root.logger.state).exporters.len()
655    }
656
657    /// Remove buffered messages without touching exporters.
658    pub fn clear_buffer(&self) {
659        lock(&self.ctx.root.logger.state).buffer.clear();
660    }
661}
662
663fn hyphenate(value: &str) -> String {
664    let mut output = String::new();
665    for (index, character) in value.chars().enumerate() {
666        if character.is_uppercase() {
667            if index > 0 {
668                output.push('-');
669            }
670            for lower in character.to_lowercase() {
671                output.push(lower);
672            }
673        } else {
674            output.push(character);
675        }
676    }
677    output
678}