mars-xlog 0.1.0

Pure Rust xlog library with async/sync appenders, compression, encryption, and tracing integration.
Documentation
//! `tracing` layer that forwards events into Mars Xlog.
//!
//! This module is gated behind the `tracing` feature.
use crate::{LogLevel, Xlog};
use std::fmt;
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::sync::Arc;
use tracing::field::{Field, Visit};
use tracing::{Event, Level, Metadata, Subscriber};
use tracing_subscriber::layer::{Context, Layer};
use tracing_subscriber::registry::LookupSpan;

/// Configuration for `XlogLayer`.
#[derive(Debug, Clone)]
pub struct XlogLayerConfig {
    /// Whether the layer should emit logs.
    pub enabled: bool,
    /// Minimum log level to forward.
    pub level: LogLevel,
    /// Optional tag override (defaults to `Metadata::target()`).
    pub tag: Option<String>,
    /// Include span names in the formatted message.
    pub include_spans: bool,
}

impl XlogLayerConfig {
    /// Create a layer config with `enabled = true`, no tag override, and
    /// span names excluded from the formatted message.
    pub fn new(level: LogLevel) -> Self {
        Self {
            enabled: true,
            level,
            tag: None,
            include_spans: false,
        }
    }

    /// Enable or disable the layer.
    pub fn enabled(mut self, enabled: bool) -> Self {
        self.enabled = enabled;
        self
    }

    /// Set the minimum forwarded log level.
    pub fn level(mut self, level: LogLevel) -> Self {
        self.level = level;
        self
    }

    /// Override the emitted tag instead of using `Metadata::target()`.
    pub fn tag(mut self, tag: impl Into<String>) -> Self {
        self.tag = Some(tag.into());
        self
    }

    /// Include the current span stack in the formatted message body.
    pub fn include_spans(mut self, include: bool) -> Self {
        self.include_spans = include;
        self
    }
}

/// Handle used to toggle a running `XlogLayer`.
#[derive(Clone)]
pub struct XlogLayerHandle {
    state: Arc<LayerState>,
}

impl XlogLayerHandle {
    /// Enable or disable forwarding.
    pub fn set_enabled(&self, enabled: bool) {
        self.state.enabled.store(enabled, Ordering::Release);
    }

    /// Check whether forwarding is enabled.
    pub fn enabled(&self) -> bool {
        self.state.enabled.load(Ordering::Acquire)
    }

    /// Update the minimum forwarded level for this layer only.
    pub fn set_level(&self, level: LogLevel) {
        self.state
            .level
            .store(level_to_u8(level), Ordering::Release);
    }

    /// Read the current minimum log level.
    pub fn level(&self) -> LogLevel {
        level_from_u8(self.state.level.load(Ordering::Acquire))
    }
}

/// `tracing-subscriber` layer that forwards events to a `Xlog` instance.
pub struct XlogLayer {
    state: Arc<LayerState>,
    tag: Option<String>,
    include_spans: bool,
}

impl XlogLayer {
    /// Build a layer using the logger's current level and defaults.
    pub fn new(logger: Xlog) -> (Self, XlogLayerHandle) {
        let config = XlogLayerConfig::new(logger.level());
        Self::with_config(logger, config)
    }

    /// Build a layer from explicit configuration.
    ///
    /// This only configures layer-side filtering and does not mutate the
    /// underlying logger's level.
    pub fn with_config(logger: Xlog, config: XlogLayerConfig) -> (Self, XlogLayerHandle) {
        let state = Arc::new(LayerState::new(logger, config.enabled, config.level));
        let layer = Self {
            state: Arc::clone(&state),
            tag: config.tag,
            include_spans: config.include_spans,
        };
        let handle = XlogLayerHandle { state };
        (layer, handle)
    }

    /// Create a new handle that can be used to reconfigure the layer.
    pub fn handle(&self) -> XlogLayerHandle {
        XlogLayerHandle {
            state: Arc::clone(&self.state),
        }
    }

    fn is_enabled_for(&self, level: LogLevel) -> bool {
        if !self.state.enabled.load(Ordering::Acquire) {
            return false;
        }
        let min_level = level_from_u8(self.state.level.load(Ordering::Acquire));
        level_rank(level) >= level_rank(min_level)
    }

    fn is_metadata_enabled(&self, metadata: &Metadata<'_>) -> bool {
        let level = tracing_level_to_log_level(metadata.level());
        level != LogLevel::None && self.is_enabled_for(level)
    }
}

impl<S> Layer<S> for XlogLayer
where
    S: Subscriber + for<'a> LookupSpan<'a>,
{
    fn enabled(&self, metadata: &Metadata<'_>, _ctx: Context<'_, S>) -> bool {
        self.is_metadata_enabled(metadata)
    }

    fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) {
        let metadata = event.metadata();
        let level = tracing_level_to_log_level(metadata.level());
        if level == LogLevel::None {
            return;
        }
        if !self.is_enabled_for(level) {
            return;
        }
        if !self.state.logger.is_enabled(level) {
            return;
        }

        let mut visitor = EventVisitor::default();
        event.record(&mut visitor);

        let mut message = visitor.finish();
        if self.include_spans {
            if let Some(scope) = ctx.event_scope(event) {
                let mut spans = String::new();
                for span in scope.from_root() {
                    if !spans.is_empty() {
                        spans.push_str(" > ");
                    }
                    spans.push_str(span.metadata().name());
                }
                if !spans.is_empty() {
                    if message.is_empty() {
                        message = spans;
                    } else {
                        message = format!("[{}] {}", spans, message);
                    }
                }
            }
        }
        if message.is_empty() {
            message = metadata.name().to_string();
        }

        let tag = self.tag.as_deref().unwrap_or_else(|| metadata.target());
        let file = metadata.file().unwrap_or("<unknown>");
        let module = metadata.module_path().unwrap_or("<unknown>");
        let line = metadata.line().unwrap_or(0);

        self.state
            .logger
            .write_with_meta(level, Some(tag), file, module, line, &message);
    }
}

struct LayerState {
    enabled: AtomicBool,
    level: AtomicU8,
    logger: Xlog,
}

impl LayerState {
    fn new(logger: Xlog, enabled: bool, level: LogLevel) -> Self {
        Self {
            enabled: AtomicBool::new(enabled),
            level: AtomicU8::new(level_to_u8(level)),
            logger,
        }
    }
}

#[derive(Default)]
struct EventVisitor {
    message: Option<String>,
    fields: Vec<(String, String)>,
}

impl EventVisitor {
    fn finish(self) -> String {
        let mut output = String::new();
        if let Some(message) = self.message {
            output.push_str(&message);
        }
        if !self.fields.is_empty() {
            if !output.is_empty() {
                output.push(' ');
            }
            output.push('{');
            for (idx, (name, value)) in self.fields.iter().enumerate() {
                if idx > 0 {
                    output.push_str(", ");
                }
                output.push_str(name);
                output.push('=');
                output.push_str(value);
            }
            output.push('}');
        }
        output
    }

    fn record_field(&mut self, field: &Field, value: String) {
        if field.name() == "message" {
            self.message = Some(value);
        } else {
            self.fields.push((field.name().to_string(), value));
        }
    }
}

impl Visit for EventVisitor {
    fn record_f64(&mut self, field: &Field, value: f64) {
        self.record_field(field, value.to_string());
    }

    fn record_i64(&mut self, field: &Field, value: i64) {
        self.record_field(field, value.to_string());
    }

    fn record_u64(&mut self, field: &Field, value: u64) {
        self.record_field(field, value.to_string());
    }

    fn record_bool(&mut self, field: &Field, value: bool) {
        self.record_field(field, value.to_string());
    }

    fn record_str(&mut self, field: &Field, value: &str) {
        self.record_field(field, value.to_string());
    }

    fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
        self.record_field(field, format!("{value:?}"));
    }
}

fn tracing_level_to_log_level(level: &Level) -> LogLevel {
    match *level {
        Level::TRACE => LogLevel::Verbose,
        Level::DEBUG => LogLevel::Debug,
        Level::INFO => LogLevel::Info,
        Level::WARN => LogLevel::Warn,
        Level::ERROR => LogLevel::Error,
    }
}

fn level_rank(level: LogLevel) -> u8 {
    match level {
        LogLevel::Verbose => 0,
        LogLevel::Debug => 1,
        LogLevel::Info => 2,
        LogLevel::Warn => 3,
        LogLevel::Error => 4,
        LogLevel::Fatal => 5,
        LogLevel::None => 6,
    }
}

fn level_to_u8(level: LogLevel) -> u8 {
    level_rank(level)
}

fn level_from_u8(value: u8) -> LogLevel {
    match value {
        0 => LogLevel::Verbose,
        1 => LogLevel::Debug,
        2 => LogLevel::Info,
        3 => LogLevel::Warn,
        4 => LogLevel::Error,
        5 => LogLevel::Fatal,
        _ => LogLevel::None,
    }
}

#[cfg(test)]
mod tests {
    use std::sync::atomic::{AtomicUsize, Ordering};

    use tempfile::TempDir;

    use super::{XlogLayer, XlogLayerConfig};
    use crate::{LogLevel, Xlog, XlogConfig};

    static NEXT_PREFIX_ID: AtomicUsize = AtomicUsize::new(1);

    fn unique_prefix() -> String {
        let id = NEXT_PREFIX_ID.fetch_add(1, Ordering::Relaxed);
        format!("tracing-layer-{}-{id}", std::process::id())
    }

    #[test]
    fn with_config_does_not_mutate_logger_level() {
        let dir = TempDir::new().expect("tempdir");
        let logger = Xlog::init(
            XlogConfig::new(dir.path().display().to_string(), unique_prefix()),
            LogLevel::Info,
        )
        .expect("init logger");
        assert_eq!(logger.level(), LogLevel::Info);

        let (_layer, _handle) =
            XlogLayer::with_config(logger.clone(), XlogLayerConfig::new(LogLevel::Debug));
        assert_eq!(logger.level(), LogLevel::Info);
    }

    #[test]
    fn handle_set_level_only_updates_layer_filter() {
        let dir = TempDir::new().expect("tempdir");
        let logger = Xlog::init(
            XlogConfig::new(dir.path().display().to_string(), unique_prefix()),
            LogLevel::Warn,
        )
        .expect("init logger");
        let (_layer, handle) =
            XlogLayer::with_config(logger.clone(), XlogLayerConfig::new(LogLevel::Info));

        assert_eq!(logger.level(), LogLevel::Warn);
        assert_eq!(handle.level(), LogLevel::Info);

        handle.set_level(LogLevel::Debug);
        assert_eq!(handle.level(), LogLevel::Debug);
        assert_eq!(logger.level(), LogLevel::Warn);
    }
}